using Google.Protobuf.WellKnownTypes; using Grpc.Core; using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.Protos.Telemetry.V1; using ZB.MOM.WW.OtOpcUa.ControlPlane.Telemetry; using Xunit; namespace ZB.MOM.WW.OtOpcUa.ControlPlane.Tests.Telemetry; /// /// Unit tests for driving a fake generated client (the ctor /// seam), so the pump/normal-shutdown/error-routing behaviour is provable without a live gRPC /// server. The wire itself is covered by the Phase 5 live gate. The central assertion throughout is /// the onError contract: only a transport fault reaches onError; programming faults throw /// synchronously and consumer-callback faults are isolated. /// public sealed class TelemetryStreamClientTests { [Fact] public async Task RunAsync_pumps_every_event_to_onEvent() { var events = new[] { new TelemetryEvent { ScriptLog = Script("one") }, new TelemetryEvent { ScriptLog = Script("two") }, }; var fake = new FakeClient(new FakeStreamReader(events)); using var sut = new TelemetryStreamClient(fake, "key"); var received = new List(); Exception? errored = null; await sut.RunAsync("corr-1", received.Add, ex => errored = ex, CancellationToken.None); received.Count.ShouldBe(2); received[0].ScriptLog.Message.ShouldBe("one"); received[1].ScriptLog.Message.ShouldBe("two"); errored.ShouldBeNull(); } [Fact] public async Task RunAsync_swallows_rpc_cancelled_without_calling_onError() { var fake = new FakeClient(new FakeStreamReader( [new TelemetryEvent { ScriptLog = Script("one") }], throwAtEnd: new RpcException(new Status(StatusCode.Cancelled, "cancelled")))); using var sut = new TelemetryStreamClient(fake, "key"); var received = new List(); Exception? errored = null; await sut.RunAsync("corr-1", received.Add, ex => errored = ex, CancellationToken.None); received.Count.ShouldBe(1); errored.ShouldBeNull(); } [Fact] public async Task RunAsync_cancelled_token_ends_without_calling_onError() { // The primary shutdown branch the supervisor's routine reconnect-teardown drives: the token // fires mid-stream, the transport surfaces an OperationCanceledException, and RunAsync returns // cleanly — never onError. using var cts = new CancellationTokenSource(); var fake = new FakeClient(new FakeStreamReader( [new TelemetryEvent { ScriptLog = Script("one") }], cancelAfterFirst: cts)); using var sut = new TelemetryStreamClient(fake, "key"); var received = new List(); Exception? errored = null; await sut.RunAsync("corr-1", received.Add, ex => errored = ex, cts.Token); received.Count.ShouldBe(1); errored.ShouldBeNull(); } [Fact] public async Task RunAsync_routes_real_rpc_failure_to_onError() { var boom = new RpcException(new Status(StatusCode.Unavailable, "node down")); var fake = new FakeClient(new FakeStreamReader([], throwAtEnd: boom)); using var sut = new TelemetryStreamClient(fake, "key"); Exception? errored = null; await sut.RunAsync("corr-1", _ => { }, ex => errored = ex, CancellationToken.None); errored.ShouldBeSameAs(boom); } [Fact] public async Task RunAsync_onEvent_exception_is_isolated_pump_continues_and_onError_not_called() { var events = new[] { new TelemetryEvent { ScriptLog = Script("poison") }, new TelemetryEvent { ScriptLog = Script("healthy") }, }; var fake = new FakeClient(new FakeStreamReader(events)); using var sut = new TelemetryStreamClient(fake, "key"); var received = new List(); Exception? errored = null; await sut.RunAsync( "corr-1", evt => { if (evt.ScriptLog.Message == "poison") throw new InvalidOperationException("unmappable event"); received.Add(evt.ScriptLog.Message); }, ex => errored = ex, CancellationToken.None); // The poison event was dropped, the pump continued to the next event, and onError never fired. received.ShouldBe(["healthy"]); errored.ShouldBeNull(); } [Fact] public async Task RunAsync_empty_correlationId_throws_synchronously_and_never_calls_onError() { var fake = new FakeClient(new FakeStreamReader([])); using var sut = new TelemetryStreamClient(fake, "key"); Exception? errored = null; await Should.ThrowAsync(() => sut.RunAsync("", _ => { }, ex => errored = ex, CancellationToken.None)); errored.ShouldBeNull(); } [Fact] public async Task RunAsync_after_dispose_throws_ObjectDisposed_and_never_calls_onError() { var fake = new FakeClient(new FakeStreamReader([])); var sut = new TelemetryStreamClient(fake, "key"); sut.Dispose(); Exception? errored = null; await Should.ThrowAsync(() => sut.RunAsync("corr-1", _ => { }, ex => errored = ex, CancellationToken.None)); errored.ShouldBeNull(); } private static ScriptLog Script(string message) => new() { ScriptId = "s", Level = "Information", Message = message, TimestampUtc = Timestamp.FromDateTime(new DateTime(2026, 7, 23, 0, 0, 0, DateTimeKind.Utc)), }; /// Fake generated client whose Subscribe returns a canned server-streaming call. private sealed class FakeClient : TelemetryStreamService.TelemetryStreamServiceClient { private readonly IAsyncStreamReader _reader; public FakeClient(IAsyncStreamReader reader) => _reader = reader; public override AsyncServerStreamingCall Subscribe( TelemetryStreamRequest request, Metadata? headers = null, DateTime? deadline = null, CancellationToken cancellationToken = default) => new( _reader, Task.FromResult(new Metadata()), () => Status.DefaultSuccess, () => [], () => { }); } /// /// Replays a fixed sequence of events. Honors the pump's (so a /// cancelled token surfaces as just as the real /// transport does), can throw a supplied exception at the end of the sequence, and can cancel a /// supplied source after delivering the first item (to drive the mid-stream cancel branch). /// private sealed class FakeStreamReader : IAsyncStreamReader { private readonly IReadOnlyList _items; private readonly Exception? _throwAtEnd; private readonly CancellationTokenSource? _cancelAfterFirst; private int _index = -1; public FakeStreamReader( IReadOnlyList items, Exception? throwAtEnd = null, CancellationTokenSource? cancelAfterFirst = null) { _items = items; _throwAtEnd = throwAtEnd; _cancelAfterFirst = cancelAfterFirst; } public TelemetryEvent Current => _items[_index]; public Task MoveNext(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); _index++; if (_index < _items.Count) { // After delivering the first item, request cancellation so the NEXT MoveNext throws // OperationCanceledException at the top — the transport-cancel shape. if (_index == 0) _cancelAfterFirst?.Cancel(); return Task.FromResult(true); } if (_throwAtEnd is not null) throw _throwAtEnd; return Task.FromResult(false); } } }