222 lines
8.5 KiB
C#
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);
|
|
}
|
|
}
|