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. /// 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_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); } 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, optionally throwing at the end. private sealed class FakeStreamReader : IAsyncStreamReader { private readonly IReadOnlyList _items; private readonly Exception? _throwAtEnd; private int _index = -1; public FakeStreamReader(IReadOnlyList items, Exception? throwAtEnd = null) { _items = items; _throwAtEnd = throwAtEnd; } public TelemetryEvent Current => _items[_index]; public Task MoveNext(CancellationToken cancellationToken) { _index++; if (_index < _items.Count) return Task.FromResult(true); if (_throwAtEnd is not null) throw _throwAtEnd; return Task.FromResult(false); } } }