test(gateway): cover failback reason, FromFeed/SinceUtc badge paths; style + bounded drain (Tests-032..035)
This commit is contained in:
@@ -312,21 +312,35 @@ public sealed class AlarmFailoverEndToEndTests
|
||||
WaitTimeout);
|
||||
|
||||
// New subscriber: its initial cache snapshot must carry the degraded flags.
|
||||
// Bound the drain so a regression that never emits SnapshotComplete fails
|
||||
// with a clean TimeoutException (via cancellation) instead of hanging.
|
||||
using CancellationTokenSource newStreamCts = new();
|
||||
using CancellationTokenSource drainTimeoutCts = new();
|
||||
drainTimeoutCts.CancelAfter(WaitTimeout);
|
||||
using CancellationTokenSource linkedDrainCts =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(newStreamCts.Token, drainTimeoutCts.Token);
|
||||
ActiveAlarmSnapshot? initialActiveAlarm = null;
|
||||
await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, newStreamCts.Token))
|
||||
try
|
||||
{
|
||||
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ActiveAlarm
|
||||
&& message.ActiveAlarm.AlarmFullReference == "Galaxy!Area.Tank02.Lo")
|
||||
await foreach (AlarmFeedMessage message in monitor.StreamAsync(null, linkedDrainCts.Token))
|
||||
{
|
||||
initialActiveAlarm = message.ActiveAlarm;
|
||||
}
|
||||
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ActiveAlarm
|
||||
&& message.ActiveAlarm.AlarmFullReference == "Galaxy!Area.Tank02.Lo")
|
||||
{
|
||||
initialActiveAlarm = message.ActiveAlarm;
|
||||
}
|
||||
|
||||
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete)
|
||||
{
|
||||
break;
|
||||
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.SnapshotComplete)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (drainTimeoutCts.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
"The new subscriber did not receive a SnapshotComplete message within the timeout.");
|
||||
}
|
||||
|
||||
Assert.NotNull(initialActiveAlarm);
|
||||
Assert.True(initialActiveAlarm!.Degraded);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
using Xunit;
|
||||
|
||||
public class GatewayLogRedactorSeamTests
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
|
||||
|
||||
public sealed class GatewayLogRedactorSeamTests
|
||||
{
|
||||
[Fact]
|
||||
public void Redact_MasksApiKeyInClientIdentity()
|
||||
{
|
||||
var redactor = new GatewayLogRedactorSeam();
|
||||
var props = new Dictionary<string, object?> { ["ClientIdentity"] = "Bearer mxgw_operator01_super-secret" };
|
||||
GatewayLogRedactorSeam redactor = new();
|
||||
Dictionary<string, object?> props = new() { ["ClientIdentity"] = "Bearer mxgw_operator01_super-secret" };
|
||||
redactor.Redact(props);
|
||||
Assert.Equal("Bearer mxgw_operator01_[redacted]", props["ClientIdentity"]);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
@@ -170,6 +171,94 @@ public sealed class DashboardBrowseAndAlarmModelTests
|
||||
Assert.True(model.IsDegraded);
|
||||
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
|
||||
Assert.Equal("x", model.Reason);
|
||||
// Tests-033: pin the amber label text, not just the CSS class — a label swap
|
||||
// would otherwise pass this test.
|
||||
Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: an explicitly-degraded status whose mode is still Alarmmgr (the
|
||||
/// <c>Degraded || Mode==Subtag</c> guard's second, independent branch) must still
|
||||
/// map to the degraded amber badge.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromProviderStatus_Alarmmgr_DegradedFlagSet_WarningBadge()
|
||||
{
|
||||
AlarmProviderStatus status = new()
|
||||
{
|
||||
Mode = AlarmProviderMode.Alarmmgr,
|
||||
Degraded = true,
|
||||
Reason = "independently degraded",
|
||||
};
|
||||
|
||||
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
|
||||
|
||||
Assert.True(model.IsDegraded);
|
||||
Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
|
||||
Assert.Contains("bg-warning", model.BadgeCssClass, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: the <c>SinceUtc</c> field must carry the protobuf <c>Since</c>
|
||||
/// timestamp converted to a <see cref="DateTimeOffset" />.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromProviderStatus_WithSinceTimestamp_MapsSinceUtc()
|
||||
{
|
||||
DateTimeOffset since = new(2026, 6, 15, 12, 30, 0, TimeSpan.Zero);
|
||||
AlarmProviderStatus status = new()
|
||||
{
|
||||
Mode = AlarmProviderMode.Subtag,
|
||||
Degraded = true,
|
||||
Reason = "x",
|
||||
Since = Timestamp.FromDateTimeOffset(since),
|
||||
};
|
||||
|
||||
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromProviderStatus(status);
|
||||
|
||||
Assert.Equal(since, model.SinceUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: <see cref="DashboardAlarmProviderStatus.FromFeed" /> — the entry the
|
||||
/// dashboard SignalR snapshot path actually calls — projects a provider-status
|
||||
/// feed message into the badge model.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromFeed_ProviderStatusPayload_ProjectsBadge()
|
||||
{
|
||||
AlarmFeedMessage message = new()
|
||||
{
|
||||
ProviderStatus = new AlarmProviderStatus
|
||||
{
|
||||
Mode = AlarmProviderMode.Subtag,
|
||||
Degraded = true,
|
||||
Reason = "alarmmgr failed",
|
||||
},
|
||||
};
|
||||
|
||||
DashboardAlarmProviderStatus model = DashboardAlarmProviderStatus.FromFeed(message);
|
||||
|
||||
Assert.Equal(AlarmProviderMode.Subtag, model.Mode);
|
||||
Assert.True(model.IsDegraded);
|
||||
Assert.Equal(DashboardAlarmProviderStatus.DegradedLabel, model.Label);
|
||||
Assert.Equal("alarmmgr failed", model.Reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests-033: <see cref="DashboardAlarmProviderStatus.FromFeed" /> throws
|
||||
/// <see cref="ArgumentException" /> when the feed message does not carry a
|
||||
/// provider-status payload.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void FromFeed_NonProviderStatusPayload_Throws()
|
||||
{
|
||||
AlarmFeedMessage message = new()
|
||||
{
|
||||
SnapshotComplete = true,
|
||||
};
|
||||
|
||||
Assert.Throws<ArgumentException>(() => DashboardAlarmProviderStatus.FromFeed(message));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user