feat(sitecallaudit): PullSiteCalls reconciliation plumbing (store read + RPC + site handler + central client)
Site Call Audit (#22): build the documented periodic reconciliation PULL self-heal path for the eventually-consistent central SiteCalls mirror, as a dedicated PullSiteCalls gRPC RPC kept separate from the audit pull. This is the pull PLUMBING only; the central reconciliation tick is a separate follow-up. - IOperationTrackingStore.ReadChangedSinceAsync(sinceUtc, batchSize): inclusive UpdatedAtUtc cursor, oldest-first, batch-capped; SQLite impl projects tracking rows onto SiteCallOperational (Kind->Channel, TargetSummary->Target, SourceSite left empty - the store has no site-id column). - sitestream.proto: rpc PullSiteCalls + PullSiteCallsRequest/Response, mirroring PullAuditEvents; regenerated checked-in SiteStreamGrpc/*.cs. - SiteCallDtoMapper.ToDto(SiteCallOperational): inverse of FromDto for the handler. - SiteStreamGrpcServer.PullSiteCalls handler + SetOperationTrackingStore seam; Host wires the seam alongside SetSiteAuditQueue (site roles only). - Central IPullSiteCallsClient + GrpcPullSiteCallsClient (home: AuditLog/Central to reuse ISiteEnumerator; SiteCallAudit does not reference AuditLog). Re-stamps SourceSite from the dialed siteId; no-throw on tolerable transport faults; SpecifyKind (not ToUniversalTime) cursor handling. Central-only DI registration. Tests: ReadChangedSinceAsync (4), PullSiteCalls handler (6), GrpcPullSiteCallsClient (8). Full solution build 0 warnings/0 errors (TreatWarningsAsErrors).
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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.Interfaces;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="SiteStreamGrpcServer.PullSiteCalls"/> (Site Call Audit
|
||||
/// #22 reconciliation handler). Verifies the request →
|
||||
/// <see cref="IOperationTrackingStore.ReadChangedSinceAsync"/> → response
|
||||
/// round-trip through the gRPC handler. The store is an NSubstitute stub so the
|
||||
/// tests never touch SQLite. Mirrors <see cref="SiteStreamPullAuditEventsTests"/>
|
||||
/// — but there is no MarkReconciled step (the tracking store is the operational
|
||||
/// source of truth; the central SiteCalls mirror is upsert-on-newer).
|
||||
/// </summary>
|
||||
public class SiteStreamPullSiteCallsTests : 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 SiteCallOperational NewOperational() =>
|
||||
new(
|
||||
TrackedOperationId: TrackedOperationId.New(),
|
||||
Channel: "ApiOutbound",
|
||||
Target: "ERP.GetOrder",
|
||||
SourceSite: string.Empty,
|
||||
SourceNode: "node-a",
|
||||
Status: "Attempted",
|
||||
RetryCount: 1,
|
||||
LastError: null,
|
||||
HttpStatus: 503,
|
||||
CreatedAtUtc: DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 0, 0), DateTimeKind.Utc),
|
||||
UpdatedAtUtc: DateTime.SpecifyKind(new DateTime(2026, 5, 20, 10, 1, 0), DateTimeKind.Utc),
|
||||
TerminalAtUtc: null);
|
||||
|
||||
[Fact]
|
||||
public async Task PullSiteCalls_NoStoreWired_ReturnsEmptyResponse()
|
||||
{
|
||||
var server = CreateServer();
|
||||
// Intentionally do NOT call SetOperationTrackingStore — simulates a
|
||||
// central-only host or a wiring-incomplete startup window.
|
||||
|
||||
var request = new PullSiteCallsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddMinutes(-5)),
|
||||
BatchSize = 100,
|
||||
};
|
||||
|
||||
var response = await server.PullSiteCalls(request, NewContext());
|
||||
|
||||
Assert.Empty(response.Operationals);
|
||||
Assert.False(response.MoreAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullSiteCalls_With5Rows_ReturnsAllFiveDtos()
|
||||
{
|
||||
var store = Substitute.For<IOperationTrackingStore>();
|
||||
var rows = Enumerable.Range(0, 5).Select(_ => NewOperational()).ToList();
|
||||
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<SiteCallOperational>)rows);
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetOperationTrackingStore(store);
|
||||
|
||||
var request = new PullSiteCallsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 100, // larger than returned count so MoreAvailable should be false
|
||||
};
|
||||
|
||||
var response = await server.PullSiteCalls(request, NewContext());
|
||||
|
||||
Assert.Equal(5, response.Operationals.Count);
|
||||
Assert.False(response.MoreAvailable); // 5 < 100
|
||||
var expectedIds = rows.Select(r => r.TrackedOperationId.ToString()).ToHashSet();
|
||||
Assert.True(expectedIds.SetEquals(response.Operationals.Select(d => d.TrackedOperationId).ToHashSet()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullSiteCalls_PassesSinceUtcThroughVerbatim()
|
||||
{
|
||||
var store = Substitute.For<IOperationTrackingStore>();
|
||||
var capturedSince = DateTime.MinValue;
|
||||
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(call =>
|
||||
{
|
||||
capturedSince = call.ArgAt<DateTime>(0);
|
||||
return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
|
||||
});
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetOperationTrackingStore(store);
|
||||
|
||||
var since = DateTime.SpecifyKind(new DateTime(2026, 5, 20, 9, 30, 0), DateTimeKind.Utc);
|
||||
var request = new PullSiteCallsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(since),
|
||||
BatchSize = 50,
|
||||
};
|
||||
|
||||
var response = await server.PullSiteCalls(request, NewContext());
|
||||
|
||||
Assert.Empty(response.Operationals);
|
||||
Assert.False(response.MoreAvailable);
|
||||
Assert.Equal(since, capturedSince);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullSiteCalls_BatchSize3_Returns3Rows_MoreAvailableTrue()
|
||||
{
|
||||
var store = Substitute.For<IOperationTrackingStore>();
|
||||
var rows = Enumerable.Range(0, 3).Select(_ => NewOperational()).ToList();
|
||||
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns((IReadOnlyList<SiteCallOperational>)rows);
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetOperationTrackingStore(store);
|
||||
|
||||
var request = new PullSiteCallsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 3,
|
||||
};
|
||||
|
||||
var response = await server.PullSiteCalls(request, NewContext());
|
||||
|
||||
Assert.Equal(3, response.Operationals.Count);
|
||||
// saturated batch → central needs to know to issue a follow-up pull
|
||||
Assert.True(response.MoreAvailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullSiteCalls_NonPositiveBatchSize_ThrowsInvalidArgument()
|
||||
{
|
||||
var store = Substitute.For<IOperationTrackingStore>();
|
||||
var server = CreateServer();
|
||||
server.SetOperationTrackingStore(store);
|
||||
|
||||
var request = new PullSiteCallsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 0,
|
||||
};
|
||||
|
||||
var ex = await Assert.ThrowsAsync<RpcException>(
|
||||
() => server.PullSiteCalls(request, NewContext()));
|
||||
Assert.Equal(StatusCode.InvalidArgument, ex.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PullSiteCalls_ReadThrows_ReturnsEmptyResponse()
|
||||
{
|
||||
// Best-effort: a read fault must never abort the reconciliation tick.
|
||||
var store = Substitute.For<IOperationTrackingStore>();
|
||||
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.ThrowsAsync(new InvalidOperationException("SQLite disposed mid-call"));
|
||||
|
||||
var server = CreateServer();
|
||||
server.SetOperationTrackingStore(store);
|
||||
|
||||
var request = new PullSiteCallsRequest
|
||||
{
|
||||
SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)),
|
||||
BatchSize = 100,
|
||||
};
|
||||
|
||||
// Must NOT throw — the handler swallows the fault to an empty response.
|
||||
var response = await server.PullSiteCalls(request, NewContext());
|
||||
|
||||
Assert.Empty(response.Operationals);
|
||||
Assert.False(response.MoreAvailable);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user