5fe7135e2c
Tasks 3.4-3.6 of the overview-dashboard plan. Polling (3.4/3.5): - OverviewPollerService: one timer at the fastest configured cadence, each target polled when its own interval is due, fan-out per sweep, no retries (the next tick is the retry, so damping stays the only place that decides whether a failure is worth showing). - OverviewSnapshotStore: atomic swap of a complete immutable graph plus a change event. Pages read it and never probe — the cache-first requirement. - LeaderResolver: per-group leader from akka-cluster data, with the split-brain flag scoped to members that are actually answering, so an ordinary failover is not misread as a disagreement. UI (3.6): ThemeShell + StatusPill/TechCard composition against the mockup; instance cards carry the status stripe (dashed for Unreachable), the check detail list and the raw ready/active signal line. Two defects found by verification rather than by review: - The active tier answers with a status code and an EMPTY body, but the client demanded parseable JSON on every probe — so a healthy pair rendered as "role unknown" instead of Active/Standby. Split into ProbeAsync (ready tier, body is the payload) and ProbeStatusAsync (active tier, code is the answer). Caught by a live smoke run against two fake health endpoints; the poller tests now serve an empty body so they hold the fix. - SweepAsync published even when cancellation was already requested, leaving a half-probed registry as the store's final state on shutdown. It now checks the token itself rather than relying on the transport to throw. Also: app.UseAntiforgery() is required despite the anonymous-by-design pipeline — AddRazorComponents stamps antiforgery metadata unconditionally and the endpoint middleware hard-fails without it (every page was 500). Chosen over DisableAntiforgery() so a future form is protected by default. 147 tests, 0 warnings. Live-verified end to end against fake endpoints: Up/Unreachable/Active/Standby, leader chip, cluster-data line, KPI counts.
98 lines
2.9 KiB
C#
98 lines
2.9 KiB
C#
using ZB.MOM.WW.Overview.Polling;
|
|
|
|
namespace ZB.MOM.WW.Overview.Tests;
|
|
|
|
/// <summary>
|
|
/// The store is the cache-first seam: a page reads it and never probes. These tests pin that it
|
|
/// always has something to serve and that subscribers hear about replacements.
|
|
/// </summary>
|
|
public class OverviewSnapshotStoreTests
|
|
{
|
|
private static OverviewSnapshot SnapshotAt(int second) =>
|
|
new(new DateTimeOffset(2026, 7, 24, 12, 0, second, TimeSpan.Zero), []);
|
|
|
|
[Fact]
|
|
public void Current_BeforeAnyPublish_IsEmptyNotNull()
|
|
{
|
|
// A page can render before the poller's first sweep finishes; a null here would be a
|
|
// NullReferenceException on the landing page of a monitoring dashboard.
|
|
var store = new OverviewSnapshotStore();
|
|
|
|
Assert.NotNull(store.Current);
|
|
Assert.Empty(store.Current.Applications);
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_ReplacesCurrent()
|
|
{
|
|
var store = new OverviewSnapshotStore();
|
|
var snapshot = SnapshotAt(1);
|
|
|
|
store.Publish(snapshot);
|
|
|
|
Assert.Same(snapshot, store.Current);
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_NotifiesSubscribersWithTheNewSnapshot()
|
|
{
|
|
var store = new OverviewSnapshotStore();
|
|
OverviewSnapshot? received = null;
|
|
store.Changed += s => received = s;
|
|
|
|
var snapshot = SnapshotAt(2);
|
|
store.Publish(snapshot);
|
|
|
|
Assert.Same(snapshot, received);
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_AfterUnsubscribe_DoesNotNotify()
|
|
{
|
|
// Blazor circuits come and go; a handler that outlives its component would call
|
|
// StateHasChanged on a disposed renderer on every sweep.
|
|
var store = new OverviewSnapshotStore();
|
|
var calls = 0;
|
|
void Handler(OverviewSnapshot _) => calls++;
|
|
|
|
store.Changed += Handler;
|
|
store.Publish(SnapshotAt(1));
|
|
store.Changed -= Handler;
|
|
store.Publish(SnapshotAt(2));
|
|
|
|
Assert.Equal(1, calls);
|
|
}
|
|
|
|
[Fact]
|
|
public void Publish_Null_Throws()
|
|
{
|
|
Assert.Throws<ArgumentNullException>(() => new OverviewSnapshotStore().Publish(null!));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Current_ReadDuringPublish_ObservesOneWholeSnapshot()
|
|
{
|
|
// The tearing guarantee: a reader always sees a complete graph, never a half-swapped one.
|
|
var store = new OverviewSnapshotStore();
|
|
var a = SnapshotAt(1);
|
|
var b = SnapshotAt(2);
|
|
store.Publish(a);
|
|
|
|
using var stop = new CancellationTokenSource();
|
|
var reader = Task.Run(() =>
|
|
{
|
|
while (!stop.IsCancellationRequested)
|
|
{
|
|
var seen = store.Current;
|
|
Assert.True(ReferenceEquals(seen, a) || ReferenceEquals(seen, b));
|
|
}
|
|
});
|
|
|
|
for (var i = 0; i < 1_000; i++)
|
|
store.Publish(i % 2 == 0 ? b : a);
|
|
|
|
await stop.CancelAsync();
|
|
await reader;
|
|
}
|
|
}
|