d95292c95d
Found by the live rig, and invisible to every unit test that existed.
LeaderResolver and InstanceCard.ClusterDataLine both looked the cluster view up
under the check name "akka-cluster". ScadaBridge registers the shared
AkkaClusterHealthCheck under that name; OtOpcUa registers the SAME check as
"akka". So once the OtOpcUa rig was rebuilt on Health 0.2.0 and started
publishing data.leader, its groups still rendered with no leader chip and no
evidence line - and a group with no votes is a legitimate state, so nothing
looked wrong. ScadaBridge's four groups were fully populated the whole time,
which is what made it look like the feature worked.
Both now find the entry by the DATA KEY ("leader"), which is the part of the
contract the shared check actually owns rather than the part each consumer names
for itself.
Regression tests: the leader is found under akka-cluster, akka, and an arbitrary
future name; and an entry that publishes data WITHOUT a leader (ScadaBridge's
localdb check, whose data carries replication counters) does not get picked in
preference to the one that answers the question. Every prior fixture used
ScadaBridge's name, which is precisely why this got through.
After the fix all seven cluster groups render a leader chip with zero
split-brain flags - design section 9 check 2 now passes for BOTH products.
174 tests, 0 warnings.
173 lines
5.7 KiB
C#
173 lines
5.7 KiB
C#
using System.Diagnostics.Metrics;
|
|
using System.Net;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ZB.MOM.WW.Overview.Polling;
|
|
|
|
namespace ZB.MOM.WW.Overview.Tests;
|
|
|
|
/// <summary>A meter factory with no listener attached, for tests that only need the poller to run.</summary>
|
|
internal sealed class DummyMeterFactory : IMeterFactory
|
|
{
|
|
private readonly List<Meter> _meters = [];
|
|
|
|
public Meter Create(MeterOptions options)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
|
|
// Stamp ourselves as the scope, exactly as the real factory does. Meter names are global,
|
|
// so a MeterListener that matched on name alone would pick up meters created by tests
|
|
// running in parallel; the scope is what makes one test's instruments identifiable.
|
|
options.Scope = this;
|
|
|
|
var meter = new Meter(options);
|
|
_meters.Add(meter);
|
|
return meter;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var meter in _meters)
|
|
meter.Dispose();
|
|
|
|
_meters.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>A clock the test drives by hand.</summary>
|
|
/// <remarks>
|
|
/// Only <see cref="GetUtcNow"/> is overridden: the poller's sweep — the part with rules worth
|
|
/// pinning — is exercised directly, so no test here needs a fake timer.
|
|
/// </remarks>
|
|
internal sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = start;
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan by) => _now += by;
|
|
}
|
|
|
|
/// <summary>Serves canned responses per absolute URL and counts what was requested.</summary>
|
|
internal sealed class RoutingHandler : HttpMessageHandler
|
|
{
|
|
private readonly Dictionary<string, Func<HttpResponseMessage>> _routes = new(StringComparer.OrdinalIgnoreCase);
|
|
|
|
public List<string> Requests { get; } = [];
|
|
|
|
public RoutingHandler Json(string url, HttpStatusCode status, string body)
|
|
{
|
|
_routes[url] = () => new HttpResponseMessage(status)
|
|
{
|
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
};
|
|
return this;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A status-code-only response with an EMPTY body — what the active tier actually returns when
|
|
/// it is a bare gate endpoint rather than a full health report.
|
|
/// </summary>
|
|
public RoutingHandler Status(string url, HttpStatusCode status)
|
|
{
|
|
_routes[url] = () => new HttpResponseMessage(status)
|
|
{
|
|
Content = new StringContent(string.Empty, Encoding.UTF8, "application/json"),
|
|
};
|
|
return this;
|
|
}
|
|
|
|
public RoutingHandler Fails(string url, string message)
|
|
{
|
|
_routes[url] = () => throw new HttpRequestException(message);
|
|
return this;
|
|
}
|
|
|
|
/// <summary>Lets a wrapping handler delegate here without re-entering the protected member.</summary>
|
|
public Task<HttpResponseMessage> HandleAsync(HttpRequestMessage request) =>
|
|
SendAsync(request, CancellationToken.None);
|
|
|
|
protected override Task<HttpResponseMessage> SendAsync(
|
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
|
{
|
|
var url = request.RequestUri!.ToString();
|
|
|
|
lock (Requests)
|
|
Requests.Add(url);
|
|
|
|
if (!_routes.TryGetValue(url, out var factory))
|
|
return Task.FromException<HttpResponseMessage>(new HttpRequestException($"no route for {url}"));
|
|
|
|
try
|
|
{
|
|
return Task.FromResult(factory());
|
|
}
|
|
catch (HttpRequestException ex)
|
|
{
|
|
return Task.FromException<HttpResponseMessage>(ex);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Hands every named client the same test handler.</summary>
|
|
internal sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory
|
|
{
|
|
public HttpClient CreateClient(string name) =>
|
|
new(handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan };
|
|
}
|
|
|
|
/// <summary>Builds canonical health bodies so tests state intent, not JSON.</summary>
|
|
internal static class HealthBody
|
|
{
|
|
public static string Ready(string status, params (string Name, string Status, string? Leader)[] entries)
|
|
{
|
|
var payload = new
|
|
{
|
|
status,
|
|
totalDurationMs = 1.5,
|
|
entries = entries.ToDictionary(
|
|
e => e.Name,
|
|
e => (object)new
|
|
{
|
|
status = e.Status,
|
|
description = $"{e.Name} says {e.Status}",
|
|
durationMs = 0.5,
|
|
data = e.Leader is null ? null : new Dictionary<string, object> { ["leader"] = e.Leader },
|
|
},
|
|
StringComparer.Ordinal),
|
|
};
|
|
|
|
return JsonSerializer.Serialize(payload);
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds <see cref="InstanceSnapshot"/> values for the pure-logic tests.</summary>
|
|
internal static class Snapshots
|
|
{
|
|
public static InstanceSnapshot Instance(
|
|
string name,
|
|
string? group = null,
|
|
string baseUrl = "http://host:8080",
|
|
bool hasActiveRole = true,
|
|
InstanceStatus? status = InstanceStatus.Up,
|
|
string? leader = null,
|
|
string clusterCheckName = "akka-cluster") =>
|
|
new()
|
|
{
|
|
Application = "App",
|
|
Instance = name,
|
|
Group = group,
|
|
BaseUrl = baseUrl,
|
|
HasActiveRole = hasActiveRole,
|
|
Status = status,
|
|
Report = leader is null ? null : ReportWithLeader(leader, clusterCheckName),
|
|
StaleAfter = TimeSpan.FromSeconds(45),
|
|
};
|
|
|
|
private static ZbHealthReport ReportWithLeader(string leader, string clusterCheckName)
|
|
{
|
|
var body = HealthBody.Ready("Healthy", (clusterCheckName, "Healthy", leader));
|
|
return JsonSerializer.Deserialize<ZbHealthReport>(body)!;
|
|
}
|
|
}
|