a279a43ed4
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
226 lines
8.2 KiB
C#
226 lines
8.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Unit tests for <see cref="TelemetryStreamClient"/> 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.
|
|
/// </summary>
|
|
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<TelemetryEvent>();
|
|
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<TelemetryEvent>();
|
|
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<TelemetryEvent>();
|
|
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<string>();
|
|
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<ArgumentException>(() =>
|
|
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<ObjectDisposedException>(() =>
|
|
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)),
|
|
};
|
|
|
|
/// <summary>Fake generated client whose Subscribe returns a canned server-streaming call.</summary>
|
|
private sealed class FakeClient : TelemetryStreamService.TelemetryStreamServiceClient
|
|
{
|
|
private readonly IAsyncStreamReader<TelemetryEvent> _reader;
|
|
|
|
public FakeClient(IAsyncStreamReader<TelemetryEvent> reader) => _reader = reader;
|
|
|
|
public override AsyncServerStreamingCall<TelemetryEvent> Subscribe(
|
|
TelemetryStreamRequest request,
|
|
Metadata? headers = null,
|
|
DateTime? deadline = null,
|
|
CancellationToken cancellationToken = default) =>
|
|
new(
|
|
_reader,
|
|
Task.FromResult(new Metadata()),
|
|
() => Status.DefaultSuccess,
|
|
() => [],
|
|
() => { });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Replays a fixed sequence of events. Honors the pump's <see cref="CancellationToken"/> (so a
|
|
/// cancelled token surfaces as <see cref="OperationCanceledException"/> 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).
|
|
/// </summary>
|
|
private sealed class FakeStreamReader : IAsyncStreamReader<TelemetryEvent>
|
|
{
|
|
private readonly IReadOnlyList<TelemetryEvent> _items;
|
|
private readonly Exception? _throwAtEnd;
|
|
private readonly CancellationTokenSource? _cancelAfterFirst;
|
|
private int _index = -1;
|
|
|
|
public FakeStreamReader(
|
|
IReadOnlyList<TelemetryEvent> items,
|
|
Exception? throwAtEnd = null,
|
|
CancellationTokenSource? cancelAfterFirst = null)
|
|
{
|
|
_items = items;
|
|
_throwAtEnd = throwAtEnd;
|
|
_cancelAfterFirst = cancelAfterFirst;
|
|
}
|
|
|
|
public TelemetryEvent Current => _items[_index];
|
|
|
|
public Task<bool> 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);
|
|
}
|
|
}
|
|
}
|