using Grpc.Core;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
///
/// ScadaBridge#140 — open streaming gRPC calls end on ApplicationStopping instead of holding
/// Kestrel's graceful drain for the whole shutdown timeout, and new streams are refused while stopping.
/// The end-to-end measurement (real Kestrel, real LocalDb sync peer, real Akka node) lives in
/// IntegrationTests/GracefulStopWithOpenSyncStreamTests.
///
public sealed class ShutdownStreamCancellationInterceptorTests
{
private static (ShutdownStreamCancellationInterceptor Interceptor, CancellationTokenSource Stopping) Create()
{
var stopping = new CancellationTokenSource();
var lifetime = Substitute.For();
lifetime.ApplicationStopping.Returns(stopping.Token);
return (new ShutdownStreamCancellationInterceptor(
lifetime, NullLogger.Instance), stopping);
}
/// An open server stream's token fires when the host starts stopping, and the call ends.
/// A task that represents the asynchronous operation.
[Fact]
public async Task OpenServerStream_EndsOnApplicationStopping()
{
var (interceptor, stopping) = Create();
using var callCts = new CancellationTokenSource();
var context = new FakeServerCallContext("/svc/Stream", callCts.Token);
var entered = new TaskCompletionSource();
ServerCallContext? seen = null;
var call = interceptor.ServerStreamingServerHandler(
"req", null!, context, async (_, _, ctx) =>
{
seen = ctx;
entered.SetResult();
await Task.Delay(Timeout.Infinite, ctx.CancellationToken).ContinueWith(_ => { });
});
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(call.IsCompleted);
Assert.Equal("/svc/Stream", seen!.Method);
stopping.Cancel();
await call.WaitAsync(TimeSpan.FromSeconds(5));
Assert.False(callCts.IsCancellationRequested, "the call's own token is not what ended it");
}
/// The handler still ends when the client goes away (the call token), as before.
/// A task that represents the asynchronous operation.
[Fact]
public async Task OpenDuplexStream_StillEndsOnTheCallToken()
{
var (interceptor, _) = Create();
using var callCts = new CancellationTokenSource();
var context = new FakeServerCallContext("/svc/Duplex", callCts.Token);
var call = interceptor.DuplexStreamingServerHandler(
null!, null!, context,
(_, _, ctx) => Task.Delay(Timeout.Infinite, ctx.CancellationToken).ContinueWith(_ => { }));
callCts.Cancel();
await call.WaitAsync(TimeSpan.FromSeconds(5));
}
/// A client stream returns its handler's response through the wrapper.
/// A task that represents the asynchronous operation.
[Fact]
public async Task ClientStream_ReturnsTheHandlerResponse()
{
var (interceptor, _) = Create();
var context = new FakeServerCallContext("/svc/Upload", CancellationToken.None);
var response = await interceptor.ClientStreamingServerHandler(
null!, context, (_, _) => Task.FromResult("done"));
Assert.Equal("done", response);
}
/// Once stopping has begun, a new stream is refused with the retryable Unavailable.
/// A task that represents the asynchronous operation.
[Fact]
public async Task NewStream_WhileStopping_IsRefusedUnavailable()
{
var (interceptor, stopping) = Create();
stopping.Cancel();
var invoked = false;
var ex = await Assert.ThrowsAsync(() => interceptor.DuplexStreamingServerHandler(
null!, null!, new FakeServerCallContext("/localdb_sync.v1.LocalDbSync/Sync", CancellationToken.None),
(_, _, _) => { invoked = true; return Task.CompletedTask; }));
Assert.Equal(StatusCode.Unavailable, ex.StatusCode);
Assert.False(invoked);
}
/// Unary calls are left alone even while stopping — finishing them is what the drain is for.
/// A task that represents the asynchronous operation.
[Fact]
public async Task UnaryCall_WhileStopping_PassesThrough()
{
var (interceptor, stopping) = Create();
stopping.Cancel();
var reply = await interceptor.UnaryServerHandler(
"req", new FakeServerCallContext("/svc/Unary", CancellationToken.None), (_, _) => Task.FromResult("ok"));
Assert.Equal("ok", reply);
}
///
/// Program.cs registers the interceptor LAST (innermost, after the auth gates) on BOTH the
/// central and the site AddGrpc pipelines.
///
[Fact]
public void Program_RegistersTheInterceptorInnermost_OnBothGrpcPipelines()
{
var program = Path.Combine(FindRepositoryRoot(), "src", "ZB.MOM.WW.ScadaBridge.Host", "Program.cs");
var root = CSharpSyntaxTree.ParseText(File.ReadAllText(program)).GetRoot();
var addGrpcLambdas = root.DescendantNodes().OfType()
.Where(i => i.Expression is MemberAccessExpressionSyntax { Name.Identifier.ValueText: "AddGrpc" })
.Select(i => i.ArgumentList.Arguments.Single().Expression)
.OfType()
.ToList();
Assert.Equal(2, addGrpcLambdas.Count);
foreach (var lambda in addGrpcLambdas)
{
var added = lambda.DescendantNodes().OfType()
.Where(n => n.Identifier.ValueText == "Add" && n.Parent is MemberAccessExpressionSyntax m
&& m.Expression.ToString().EndsWith("Interceptors", StringComparison.Ordinal))
.Select(n => n.TypeArgumentList.Arguments.Single().ToString())
.ToList();
Assert.True(added.Count >= 2, $"expected an auth gate and the shutdown interceptor, got [{string.Join(", ", added)}]");
Assert.Equal(nameof(ShutdownStreamCancellationInterceptor), added[^1]);
}
}
private static string FindRepositoryRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.ScadaBridge.slnx")))
dir = dir.Parent;
return dir?.FullName ?? throw new InvalidOperationException("repository root not found");
}
/// Minimal hand-rolled (grpc-dotnet ships no test context).
private sealed class FakeServerCallContext(string method, CancellationToken token) : ServerCallContext
{
///
protected override string MethodCore => method;
///
protected override string HostCore => "localhost";
///
protected override string PeerCore => "ipv4:127.0.0.1:12345";
///
protected override DateTime DeadlineCore => DateTime.UtcNow.AddMinutes(1);
///
protected override Metadata RequestHeadersCore { get; } = [];
///
protected override CancellationToken CancellationTokenCore => token;
///
protected override Metadata ResponseTrailersCore { get; } = [];
///
protected override Status StatusCore { get; set; }
///
protected override WriteOptions? WriteOptionsCore { get; set; }
///
protected override AuthContext AuthContextCore { get; } =
new(null, new Dictionary>());
///
protected override IDictionary