Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Communication.Tests/SiteStreamPullSiteCallsTests.cs
T

222 lines
8.5 KiB
C#

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_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<IOperationTrackingStore>();
var captured = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc); // sentinel
store.ReadChangedSinceAsync(Arg.Any<DateTime>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(call =>
{
captured = call.ArgAt<DateTime>(0);
return (IReadOnlyList<SiteCallOperational>)Array.Empty<SiteCallOperational>();
});
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<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);
}
}