fix(mesh-phase5): harden telemetry client onError contract (validate/isolate/defend)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
+53
@@ -233,6 +233,59 @@ public sealed class TelemetryProtoMapCentralTests
|
||||
TelemetryProtoMapCentral.ToResilience(proto).LastBreakerOpenUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToAlarm_missing_required_timestamp_throws_clear_InvalidOperation()
|
||||
{
|
||||
var proto = new AlarmTransition
|
||||
{
|
||||
AlarmId = "a", EquipmentPath = "p", AlarmName = "n", TransitionKind = "Activated",
|
||||
Severity = 1, Message = "m", User = "system", AlarmTypeName = "AlarmCondition",
|
||||
// TimestampUtc deliberately unset — an out-of-contract sender.
|
||||
};
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToAlarm(proto));
|
||||
ex.Message.ShouldContain("AlarmTransition");
|
||||
ex.Message.ShouldContain("timestamp_utc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToScript_missing_required_timestamp_throws_clear_InvalidOperation()
|
||||
{
|
||||
var proto = new ScriptLog { ScriptId = "s", Level = "Information", Message = "m" };
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToScript(proto));
|
||||
ex.Message.ShouldContain("ScriptLog");
|
||||
ex.Message.ShouldContain("timestamp_utc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToHealth_missing_required_timestamp_throws_clear_InvalidOperation()
|
||||
{
|
||||
var proto = new DriverHealth
|
||||
{
|
||||
ClusterId = "c", DriverInstanceId = "d", State = "Healthy", ErrorCount5Min = 0,
|
||||
// PublishedUtc unset.
|
||||
};
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToHealth(proto));
|
||||
ex.Message.ShouldContain("DriverHealth");
|
||||
ex.Message.ShouldContain("published_utc");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToResilience_missing_required_timestamp_throws_clear_InvalidOperation()
|
||||
{
|
||||
var proto = new DriverResilienceStatus
|
||||
{
|
||||
DriverInstanceId = "d", HostName = "h", BreakerOpen = false,
|
||||
ConsecutiveFailures = 0, CurrentInFlight = 0,
|
||||
// LastSampledUtc + PublishedUtc unset.
|
||||
};
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => TelemetryProtoMapCentral.ToResilience(proto));
|
||||
ex.Message.ShouldContain("DriverResilienceStatus");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapEvent_routes_each_case_to_its_record_type()
|
||||
{
|
||||
|
||||
+103
-3
@@ -10,7 +10,9 @@ 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.
|
||||
/// 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
|
||||
{
|
||||
@@ -53,6 +55,27 @@ public sealed class TelemetryStreamClientTests
|
||||
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()
|
||||
{
|
||||
@@ -67,6 +90,65 @@ public sealed class TelemetryStreamClientTests
|
||||
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",
|
||||
@@ -95,26 +177,44 @@ public sealed class TelemetryStreamClientTests
|
||||
() => { });
|
||||
}
|
||||
|
||||
/// <summary>Replays a fixed sequence of events, optionally throwing at the end.</summary>
|
||||
/// <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)
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user