Resolve all 46 low-severity architecture review findings

This commit is contained in:
Joseph Doherty
2026-09-09 22:56:36 -04:00
parent fef3e7675f
commit 9ee402bbcd
139 changed files with 2621 additions and 1082 deletions
@@ -19,6 +19,64 @@ public class SitePairChannelProviderDisposalTests
Options.Create(new CommunicationOptions()),
NullLogger<SitePairChannelProvider>.Instance);
[Fact]
public async Task Shutdown_RetainsActiveChannelThenDisposesItAtCompletion()
{
using var provider = Build();
provider.UpdateSite("site-1", "http://localhost:15101", null);
var entered = new TaskCompletionSource<GrpcChannel>(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var call = provider.ExecuteAsync("site-1", async (channel, ct) =>
{
entered.SetResult(channel);
await release.Task;
_ = channel.CreateCallInvoker();
return 42;
}, CancellationToken.None);
var channel = await entered.Task;
try
{
provider.Dispose();
Assert.Equal(1, provider.ParkedChannelCount);
_ = channel.CreateCallInvoker();
Assert.Throws<ObjectDisposedException>(() => provider.UpdateSite("site-2", "http://localhost:15102", null));
}
finally { release.TrySetResult(); }
Assert.Equal(42, await call);
Assert.Equal(0, provider.ParkedChannelCount);
Assert.Throws<ObjectDisposedException>(() => channel.CreateCallInvoker());
}
[Fact]
public async Task RetiredChannel_SweepKeepsItAliveUntilActualCallCompletion()
{
using var provider = Build();
provider.UpdateSite("site-1", "http://localhost:15101", null);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
GrpcChannel? held = null;
var call = provider.ExecuteAsync("site-1", async (channel, ct) =>
{
held = channel;
entered.SetResult();
await release.Task;
return 42;
}, CancellationToken.None);
try
{
await entered.Task;
provider.UpdateSite("site-1", "http://localhost:15102", null);
// Simulates any elapsed grace, including waits longer than five minutes.
provider.SweepGraveyard(force: true);
Assert.Equal(1, provider.ParkedChannelCount);
_ = held!.CreateCallInvoker();
}
finally { release.TrySetResult(); await call; }
provider.SweepGraveyard(force: true);
Assert.Equal(0, provider.ParkedChannelCount);
Assert.Throws<ObjectDisposedException>(() => held!.CreateCallInvoker());
}
[Fact]
public async Task ApplyThenUnavailable_DoesNotReplayAnAmbiguousOperation()
{
@@ -8,6 +8,15 @@ namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;
public class SiteStreamGrpcClientTests
{
[Fact]
public void ConnectedHeaderTimeout_IsImmutablePerClient()
{
var property = typeof(SiteStreamGrpcClient).GetProperty("ConnectedHeaderTimeout",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Static)!;
Assert.False(property.GetMethod!.IsStatic);
Assert.Null(property.SetMethod);
}
[Fact]
public void ConvertToDomainEvent_AttributeChanged_MapsCorrectly()
{
@@ -436,32 +445,23 @@ public class SiteStreamGrpcClientTests
// The bug: a header TIMEOUT used to be reported as connected. An unreachable/wedged
// site produces exactly that shape, so the aggregator cleared _streamDown, consumed
// its pending re-seed and fanned a full snapshot out at a site that never answered.
var previous = SiteStreamGrpcClient.ConnectedHeaderTimeout;
SiteStreamGrpcClient.ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50);
try
{
var client = SiteStreamGrpcClient.CreateForTesting();
var cts = new CancellationTokenSource();
var connected = 0;
var neverArrives = new TaskCompletionSource<Metadata>();
var client = SiteStreamGrpcClient.CreateForTesting(new CommunicationOptions { ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50) });
var cts = new CancellationTokenSource();
var connected = 0;
var neverArrives = new TaskCompletionSource<Metadata>();
// No headers AND no events: the site is dead. The stream ends (status OK) with
// no connected signal ever raised.
await client.ConsumeStreamAsync(
"corr-dead",
cts,
() => FakeCall(new StubStreamReader(), neverArrives.Task),
_ => { },
_ => { },
() => { },
() => connected++);
// No headers AND no events: the site is dead. The stream ends (status OK) with
// no connected signal ever raised.
await client.ConsumeStreamAsync(
"corr-dead",
cts,
() => FakeCall(new StubStreamReader(), neverArrives.Task),
_ => { },
_ => { },
() => { },
() => connected++);
Assert.Equal(0, connected);
}
finally
{
SiteStreamGrpcClient.ConnectedHeaderTimeout = previous;
}
Assert.Equal(0, connected);
}
[Fact]
@@ -470,42 +470,33 @@ public class SiteStreamGrpcClientTests
// The case the timeout was originally added for — a peer that defers its headers
// until the first message — is now covered by the event itself, which is real proof
// of a live peer. Connected must precede the event's delivery and fire only once.
var previous = SiteStreamGrpcClient.ConnectedHeaderTimeout;
SiteStreamGrpcClient.ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50);
try
{
var client = SiteStreamGrpcClient.CreateForTesting();
var cts = new CancellationTokenSource();
var connected = 0;
var connectedBeforeFirstEvent = false;
var events = 0;
var neverArrives = new TaskCompletionSource<Metadata>();
var client = SiteStreamGrpcClient.CreateForTesting(new CommunicationOptions { ConnectedHeaderTimeout = TimeSpan.FromMilliseconds(50) });
var cts = new CancellationTokenSource();
var connected = 0;
var connectedBeforeFirstEvent = false;
var events = 0;
var neverArrives = new TaskCompletionSource<Metadata>();
await client.ConsumeStreamAsync(
"corr-late-headers",
cts,
() => FakeCall(
new StubStreamReader(
new SiteStreamEvent { CorrelationId = "corr-late-headers" },
new SiteStreamEvent { CorrelationId = "corr-late-headers" }),
neverArrives.Task),
_ =>
{
if (events == 0) connectedBeforeFirstEvent = connected == 1;
events++;
},
_ => { },
() => { },
() => connected++);
await client.ConsumeStreamAsync(
"corr-late-headers",
cts,
() => FakeCall(
new StubStreamReader(
new SiteStreamEvent { CorrelationId = "corr-late-headers" },
new SiteStreamEvent { CorrelationId = "corr-late-headers" }),
neverArrives.Task),
_ =>
{
if (events == 0) connectedBeforeFirstEvent = connected == 1;
events++;
},
_ => { },
() => { },
() => connected++);
Assert.Equal(2, events);
Assert.Equal(1, connected);
Assert.True(connectedBeforeFirstEvent);
}
finally
{
SiteStreamGrpcClient.ConnectedHeaderTimeout = previous;
}
Assert.Equal(2, events);
Assert.Equal(1, connected);
Assert.True(connectedBeforeFirstEvent);
}
// ── R2: batch unpacking on the client ───────────────────────────────────────
@@ -514,12 +505,12 @@ public class SiteStreamGrpcClientTests
{
AttributeChanged = new AttributeValueUpdate
{
InstanceUniqueName = "SiteA.Pump01",
AttributePath = "Modules.IO",
AttributeName = "Seq",
Value = value,
Quality = Quality.Good,
Timestamp = Timestamp.FromDateTimeOffset(ts)
InstanceUniqueName = "SiteA.Pump01",
AttributePath = "Modules.IO",
AttributeName = "Seq",
Value = value,
Quality = Quality.Good,
Timestamp = Timestamp.FromDateTimeOffset(ts)
}
};
@@ -0,0 +1,46 @@
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
public class LowReviewTransportTests
{
private static SiteStreamGrpcServer Server() => new(
Substitute.For<ISiteStreamSubscriber>(), NullLogger<SiteStreamGrpcServer>.Instance);
[Theory]
[InlineData(2001)]
[InlineData(int.MaxValue)]
public async Task Pulls_RejectOversizedRowRequestsBeforeReading(int size)
{
var server = Server();
var queue = Substitute.For<ISiteAuditQueue>();
var store = Substitute.For<IOperationTrackingStore>();
server.SetSiteAuditQueue(queue);
server.SetOperationTrackingStore(store);
var context = Substitute.For<ServerCallContext>();
var audit = await Assert.ThrowsAsync<RpcException>(() => server.PullAuditEvents(
new PullAuditEventsRequest { BatchSize = size }, context));
var calls = await Assert.ThrowsAsync<RpcException>(() => server.PullSiteCalls(
new PullSiteCallsRequest { BatchSize = size }, context));
Assert.Equal(StatusCode.InvalidArgument, audit.StatusCode);
Assert.Equal(StatusCode.InvalidArgument, calls.StatusCode);
Assert.Empty(queue.ReceivedCalls());
Assert.Empty(store.ReceivedCalls());
}
[Fact]
public async Task RetiredSiteIngestRpcs_ReturnUnimplementedEvenForEmptyBatches()
{
var server = Server();
var context = Substitute.For<ServerCallContext>();
var audit = await Assert.ThrowsAsync<RpcException>(() => server.IngestAuditEvents(new(), context));
var cached = await Assert.ThrowsAsync<RpcException>(() => server.IngestCachedTelemetry(new(), context));
Assert.Equal(StatusCode.Unimplemented, audit.StatusCode);
Assert.Equal(StatusCode.Unimplemented, cached.StatusCode);
}
}
@@ -1,100 +1,24 @@
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
public class SiteStreamIngestAuditEventsTests
{
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
private SiteStreamGrpcServer CreateServer() =>
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
private static ServerCallContext NewContext(CancellationToken ct = default)
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RetiredMethodRejectsEmptyAndNonEmptyBatches(bool nonEmpty)
{
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));
});
}
var server = new SiteStreamGrpcServer(Substitute.For<ISiteStreamSubscriber>(),
NullLogger<SiteStreamGrpcServer>.Instance);
var request = new AuditEventBatch();
if (nonEmpty) request.Events.Add(new AuditEventDto());
var error = await Assert.ThrowsAsync<RpcException>(() => server.IngestAuditEvents(
request, Substitute.For<ServerCallContext>()));
Assert.Equal(StatusCode.Unimplemented, error.StatusCode);
Assert.Contains("CentralControlService", error.Status.Detail);
}
}
@@ -1,121 +1,24 @@
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
public class SiteStreamIngestCachedTelemetryTests
{
private readonly ISiteStreamSubscriber _subscriber = Substitute.For<ISiteStreamSubscriber>();
private SiteStreamGrpcServer CreateServer() =>
new(_subscriber, NullLogger<SiteStreamGrpcServer>.Instance);
private static ServerCallContext NewContext(CancellationToken ct = default)
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task RetiredMethodRejectsEmptyAndNonEmptyBatches(bool nonEmpty)
{
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));
});
}
var server = new SiteStreamGrpcServer(Substitute.For<ISiteStreamSubscriber>(),
NullLogger<SiteStreamGrpcServer>.Instance);
var request = new CachedTelemetryBatch();
if (nonEmpty) request.Packets.Add(new CachedTelemetryPacket());
var error = await Assert.ThrowsAsync<RpcException>(() => server.IngestCachedTelemetry(
request, Substitute.For<ServerCallContext>()));
Assert.Equal(StatusCode.Unimplemented, error.StatusCode);
Assert.Contains("CentralControlService", error.Status.Detail);
}
}