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; /// /// Tests for (Site Call Audit /// #22 reconciliation handler). Verifies the request → /// → response /// round-trip through the gRPC handler. The store is an NSubstitute stub so the /// tests never touch SQLite. Mirrors /// — but there is no MarkReconciled step (the tracking store is the operational /// source of truth; the central SiteCalls mirror is upsert-on-newer). /// public class SiteStreamPullSiteCallsTests : TestKit { private readonly ISiteStreamSubscriber _subscriber = Substitute.For(); private SiteStreamGrpcServer CreateServer() => new(_subscriber, NullLogger.Instance); private static ServerCallContext NewContext(CancellationToken ct = default) { var context = Substitute.For(); 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(); var rows = Enumerable.Range(0, 5).Select(_ => NewOperational()).ToList(); store.ReadChangedSinceAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((IReadOnlyList)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(); var capturedSince = DateTime.MinValue; store.ReadChangedSinceAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(call => { capturedSince = call.ArgAt(0); return (IReadOnlyList)Array.Empty(); }); 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_SinceUtcUnset_PassesDateTimeMinValue() { // First reconciliation cycle: central has no cursor yet, so the request's // SinceUtc wrapper is absent (null). The handler must default to // DateTime.MinValue ("pull from the beginning of recorded history") // without a null-deref — this proves the very first cycle doesn't crash. var store = Substitute.For(); var captured = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc); // sentinel store.ReadChangedSinceAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(call => { captured = call.ArgAt(0); return (IReadOnlyList)Array.Empty(); }); var server = CreateServer(); server.SetOperationTrackingStore(store); // SinceUtc intentionally left unset (null) — the proto wrapper is absent. var request = new PullSiteCallsRequest { BatchSize = 100, }; var response = await server.PullSiteCalls(request, NewContext()); Assert.Empty(response.Operationals); Assert.False(response.MoreAvailable); Assert.Equal(DateTime.MinValue, captured); } [Fact] public async Task PullSiteCalls_BatchSize3_Returns3Rows_MoreAvailableTrue() { var store = Substitute.For(); var rows = Enumerable.Range(0, 3).Select(_ => NewOperational()).ToList(); store.ReadChangedSinceAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns((IReadOnlyList)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(); var server = CreateServer(); server.SetOperationTrackingStore(store); var request = new PullSiteCallsRequest { SinceUtc = Timestamp.FromDateTime(DateTime.UtcNow.AddHours(-1)), BatchSize = 0, }; var ex = await Assert.ThrowsAsync( () => 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(); store.ReadChangedSinceAsync(Arg.Any(), Arg.Any(), Arg.Any()) .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); } }