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; /// A meter factory with no listener attached, for tests that only need the poller to run. internal sealed class DummyMeterFactory : IMeterFactory { private readonly List _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(); } } /// A clock the test drives by hand. /// /// Only is overridden: the poller's sweep — the part with rules worth /// pinning — is exercised directly, so no test here needs a fake timer. /// internal sealed class FakeTimeProvider(DateTimeOffset start) : TimeProvider { private DateTimeOffset _now = start; public override DateTimeOffset GetUtcNow() => _now; public void Advance(TimeSpan by) => _now += by; } /// Serves canned responses per absolute URL and counts what was requested. internal sealed class RoutingHandler : HttpMessageHandler { private readonly Dictionary> _routes = new(StringComparer.OrdinalIgnoreCase); public List 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; } /// /// 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. /// 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; } /// Lets a wrapping handler delegate here without re-entering the protected member. public Task HandleAsync(HttpRequestMessage request) => SendAsync(request, CancellationToken.None); protected override Task 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(new HttpRequestException($"no route for {url}")); try { return Task.FromResult(factory()); } catch (HttpRequestException ex) { return Task.FromException(ex); } } } /// Hands every named client the same test handler. internal sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { public HttpClient CreateClient(string name) => new(handler, disposeHandler: false) { Timeout = Timeout.InfiniteTimeSpan }; } /// Builds canonical health bodies so tests state intent, not JSON. 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 { ["leader"] = e.Leader }, }, StringComparer.Ordinal), }; return JsonSerializer.Serialize(payload); } /// /// Builds a body whose entries carry arbitrary data, for the cases where the key under /// test is not leader — notably the active tier's activeNode. /// public static string WithData(string status, params (string Name, string Status, Dictionary? Data)[] 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.Data, }, StringComparer.Ordinal), }; return JsonSerializer.Serialize(payload); } } /// Builds values for the pure-logic tests. 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(body)!; } }