test(gateway): cover failback reason, FromFeed/SinceUtc badge paths; style + bounded drain (Tests-032..035)

This commit is contained in:
Joseph Doherty
2026-06-15 02:46:06 -04:00
parent b57d02cc4d
commit 56dd56954b
5 changed files with 328 additions and 15 deletions
@@ -218,6 +218,126 @@ public sealed class GatewayAlarmMonitorProviderModeTests
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// Tests-032: pins the monitor's <c>toMode → AlarmProviderSwitchReason</c>
/// derivation (<c>GatewayAlarmMonitor.ApplyProviderModeChangeAsync</c>): an
/// alarmmgr→subtag change must emit <c>reason=failover</c> and a subtag→alarmmgr
/// change must emit <c>reason=failback</c>. Captures the <c>reason</c> tag off the
/// <c>mxgateway.alarms.provider_switches</c> counter — a regression that swapped
/// the Failover/Failback arms or collapsed them to Unknown would be caught here,
/// whereas the count-only tests above would still pass.
/// </summary>
[Fact]
public async Task ProviderModeChange_FailoverThenFailback_RecordsCorrectReasonTags()
{
using GatewayMetrics metrics = new();
List<string> capturedReasons = [];
using MeterListener listener = new();
listener.InstrumentPublished = (instrument, meterListener) =>
{
if (ReferenceEquals(instrument.Meter, metrics.Meter)
&& instrument.Name == "mxgateway.alarms.provider_switches")
{
meterListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<long>(
(instrument, _, tags, _) =>
{
if (!ReferenceEquals(instrument.Meter, metrics.Meter)
|| instrument.Name != "mxgateway.alarms.provider_switches")
{
return;
}
foreach (KeyValuePair<string, object?> tag in tags)
{
if (tag.Key == "reason" && tag.Value is string reasonTag)
{
lock (capturedReasons)
{
capturedReasons.Add(reasonTag);
}
}
}
});
listener.Start();
FakeSessionManager sessions = new();
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForSubscribeAsync(WaitTimeout);
// Register a live subscriber and gate the mode-change events until the baseline
// ProviderStatus message has been drained, so neither event is dropped.
List<AlarmFeedMessage> received = [];
TaskCompletionSource baselineReceived = new(TaskCreationOptions.RunContinuationsAsynchronously);
using CancellationTokenSource streamCts = new();
Task reader = Task.Run(async () =>
{
try
{
await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, streamCts.Token))
{
lock (received)
{
received.Add(message);
if (received.Count == 1)
{
baselineReceived.TrySetResult();
}
}
}
}
catch (OperationCanceledException)
{
// Expected when the test cancels the stream.
}
});
await baselineReceived.Task.WaitAsync(WaitTimeout);
// alarmmgr (baseline) → subtag: must classify as a failover.
sessions.EmitEvent(new MxEvent
{
OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
{
Mode = AlarmProviderMode.Subtag,
Reason = "alarmmgr failed",
At = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
},
});
await WaitUntilAsync(
() => { lock (capturedReasons) { return capturedReasons.Count >= 1; } },
WaitTimeout);
// subtag → alarmmgr: must classify as a failback.
sessions.EmitEvent(new MxEvent
{
OnAlarmProviderModeChanged = new OnAlarmProviderModeChangedEvent
{
Mode = AlarmProviderMode.Alarmmgr,
Reason = "alarmmgr recovered",
At = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow),
},
});
await WaitUntilAsync(
() => { lock (capturedReasons) { return capturedReasons.Count >= 2; } },
WaitTimeout);
lock (capturedReasons)
{
Assert.Equal(new[] { "failover", "failback" }, capturedReasons);
}
await streamCts.CancelAsync();
await reader;
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
[Fact]
public async Task NewSubscriber_ReceivesProviderStatusAsFirstMessage()
{