513fa1aa16
Comment/XML-doc changes only: - add summaries to ~10k test methods, fixtures, fakes and helpers (+ <param> on theories, <returns> on async Task methods, <inheritdoc/> on interface implementations the checker can resolve) - strip ~2.6k project-tracking references (WP*, P*-*, row-NN, Task N, #NN, Component-0NN) from comments, keeping the explanatory text; reword false positives in comments only - real docs on fakes implementing BCL/library interfaces (IDisposable, ILogger, IOptionsMonitor, IAsyncLifetime) - reword the last 5 src/ false positives (CWE-1236, failures-1, year-0001, M365) Only non-comment edits: four one-line nested classes split across lines so members can carry docs. Build 0 errors / 0 warnings.
199 lines
8.3 KiB
C#
199 lines
8.3 KiB
C#
using Akka.Actor;
|
|
using Akka.TestKit;
|
|
using Akka.TestKit.Xunit2;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
|
|
using ZB.MOM.WW.ScadaBridge.Communication;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
|
|
|
|
/// <summary>
|
|
/// Behaviour of <see cref="CentralControlGrpcService"/> that does not need a real gRPC pipeline:
|
|
/// the readiness gate, the fire-and-forget heartbeat contract, the ingest-timeout status
|
|
/// mapping, and the shared audit-ingest timeout constant. The auth interceptor + real transport
|
|
/// are proven separately in <c>CentralControlEndToEndTests</c> (Host.Tests).
|
|
/// </summary>
|
|
public class CentralControlGrpcServiceTests : TestKit
|
|
{
|
|
private static ServerCallContext NewContext(CancellationToken ct = default)
|
|
{
|
|
var context = Substitute.For<ServerCallContext>();
|
|
context.CancellationToken.Returns(ct);
|
|
return context;
|
|
}
|
|
|
|
private CentralControlGrpcService CreateService(CommunicationOptions? options = null)
|
|
=> new(
|
|
NullLogger<CentralControlGrpcService>.Instance,
|
|
Options.Create(options ?? new CommunicationOptions()));
|
|
|
|
/// <summary>Verifies that a unary call made before the service is set ready fails as unavailable.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task BeforeSetReady_AUnaryCall_IsUnavailable()
|
|
{
|
|
// Nothing was dispatched, so Unavailable is the right status — it tells a site transport
|
|
// the call never ran and a cross-node retry is safe.
|
|
var service = CreateService();
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
|
() => service.SubmitNotification(new NotificationSubmitDto(), NewContext()));
|
|
|
|
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that a non-empty ingest batch sent before the service is set ready fails as unavailable.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task BeforeSetReady_ANonEmptyIngestBatch_IsUnavailable()
|
|
{
|
|
var service = CreateService();
|
|
var batch = new AuditEventBatch();
|
|
batch.Events.Add(NewAuditDto());
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
|
() => service.IngestAuditEvents(batch, NewContext()));
|
|
|
|
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that a unary call made after the service is set ready reaches the actor.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task AfterSetReady_AUnaryCall_ReachesTheActor()
|
|
{
|
|
var stub = Sys.ActorOf(Props.Create(() => new StubActor()));
|
|
var service = CreateService();
|
|
service.SetReady(stub);
|
|
|
|
var ack = await service.SubmitNotification(NewNotificationDto(), NewContext());
|
|
|
|
Assert.True(ack.Accepted);
|
|
}
|
|
|
|
/// <summary>Verifies that a heartbeat before the service is set ready succeeds and is dropped.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Heartbeat_BeforeSetReady_SucceedsAndIsDropped()
|
|
{
|
|
// Fire-and-forget end-to-end: a heartbeat must never fault the site's timer, so even
|
|
// with no actor wired the call returns OK rather than Unavailable.
|
|
var service = CreateService();
|
|
|
|
var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext());
|
|
|
|
Assert.NotNull(reply);
|
|
}
|
|
|
|
/// <summary>Verifies that a heartbeat after the service is set ready is told to the actor and never asked.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task Heartbeat_AfterSetReady_TellsTheActor_AndNeverAsks()
|
|
{
|
|
// A black-hole actor that never replies would hang an Ask forever; the heartbeat still
|
|
// returns immediately, proving it is a Tell, not an Ask.
|
|
var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor()));
|
|
var service = CreateService();
|
|
service.SetReady(blackHole);
|
|
|
|
var reply = await service.Heartbeat(NewHeartbeatDto(), NewContext());
|
|
|
|
Assert.NotNull(reply);
|
|
}
|
|
|
|
/// <summary>Verifies that a call to an actor that never replies fails with deadline exceeded rather than unavailable.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task WhenTheActorNeverReplies_TheCall_IsDeadlineExceeded_NotUnavailable()
|
|
{
|
|
// The message WAS delivered (Unavailable would wrongly invite a duplicate retry on the
|
|
// peer node); a timeout is DeadlineExceeded, which callers never cross-node-retry.
|
|
var blackHole = Sys.ActorOf(Props.Create(() => new NeverRepliesActor()));
|
|
var service = CreateService(new CommunicationOptions
|
|
{
|
|
NotificationForwardTimeout = TimeSpan.FromMilliseconds(200),
|
|
});
|
|
service.SetReady(blackHole);
|
|
|
|
var ex = await Assert.ThrowsAsync<RpcException>(
|
|
() => service.SubmitNotification(NewNotificationDto(), NewContext()));
|
|
|
|
Assert.Equal(StatusCode.DeadlineExceeded, ex.StatusCode);
|
|
}
|
|
|
|
/// <summary>Verifies that an empty ingest batch short-circuits even before the service is set ready.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task EmptyIngestBatch_ShortCircuits_EvenBeforeSetReady()
|
|
{
|
|
// An empty batch is a no-op the actor need never see; it must not depend on readiness.
|
|
var service = CreateService();
|
|
|
|
var ack = await service.IngestAuditEvents(new AuditEventBatch(), NewContext());
|
|
|
|
Assert.Empty(ack.AcceptedEventIds);
|
|
}
|
|
|
|
/// <summary>Verifies that the audit ingest timeout is the single shared constant.</summary>
|
|
[Fact]
|
|
public void TheAuditIngestTimeout_IsTheOneSharedConstant()
|
|
{
|
|
// The plan calls SiteStreamGrpcServer.AuditIngestAskTimeout "one source of truth" shared
|
|
// between the two audit-ingest transports; the central service must not re-declare 30s.
|
|
Assert.Equal(TimeSpan.FromSeconds(30), SiteStreamGrpcServer.AuditIngestAskTimeout);
|
|
}
|
|
|
|
// The mapper reads SiteEnqueuedAt unconditionally, so a DTO that reaches mapping must carry
|
|
// a timestamp. Only DTOs that get past the readiness/auth gate map, so the negative tests
|
|
// above can pass a bare DTO.
|
|
private static NotificationSubmitDto NewNotificationDto() => new()
|
|
{
|
|
NotificationId = Guid.NewGuid().ToString(),
|
|
ListName = "ops",
|
|
SiteEnqueuedAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
|
|
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
|
|
};
|
|
|
|
private static HeartbeatDto NewHeartbeatDto() => new()
|
|
{
|
|
SiteId = "site-a",
|
|
NodeHostname = "node-a",
|
|
IsActive = true,
|
|
Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
|
|
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
|
|
};
|
|
|
|
private static AuditEventDto NewAuditDto() => new()
|
|
{
|
|
EventId = Guid.NewGuid().ToString(),
|
|
OccurredAtUtc = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
|
|
DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc)),
|
|
Channel = "ApiOutbound",
|
|
Kind = "ApiCall",
|
|
Status = "Delivered",
|
|
SourceSiteId = "site-a",
|
|
};
|
|
|
|
private sealed class StubActor : ReceiveActor
|
|
{
|
|
/// <summary>Creates the actor, which acknowledges every notification submit as accepted.</summary>
|
|
public StubActor()
|
|
{
|
|
Receive<NotificationSubmit>(msg =>
|
|
Sender.Tell(new NotificationSubmitAck(msg.NotificationId, Accepted: true, Error: null)));
|
|
}
|
|
}
|
|
|
|
/// <summary>Swallows every message and never replies, so an Ask against it times out.</summary>
|
|
private sealed class NeverRepliesActor : ReceiveActor
|
|
{
|
|
/// <summary>Creates the actor, which ignores every message.</summary>
|
|
public NeverRepliesActor() => ReceiveAny(_ => { });
|
|
}
|
|
}
|