using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Commons.Messages.Health; namespace ScadaLink.HealthMonitoring.Tests; /// /// HealthMonitoring-009 regression: the central self-report loop had no test /// coverage at all. These tests exercise leader-only gating (SelfIsPrimary), /// self-report generation for siteId="central", and monotonic sequence /// assignment. /// public class CentralHealthReportLoopTests { private sealed class FakeClusterNodeProvider : IClusterNodeProvider { public bool SelfIsPrimary { get; set; } public IReadOnlyList Nodes { get; set; } = []; public IReadOnlyList GetClusterNodes() => Nodes; } private sealed class RecordingAggregator : ICentralHealthAggregator { public List Processed { get; } = []; public void ProcessReport(SiteHealthReport report) => Processed.Add(report); public void MarkHeartbeat(string siteId, DateTimeOffset receivedAt) { } public IReadOnlyDictionary GetAllSiteStates() => new Dictionary(); public SiteHealthState? GetSiteState(string siteId) => null; } private static async Task RunLoopBriefly(CentralHealthReportLoop loop, int runForMs) { using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(runForMs + 100)); try { await loop.StartAsync(cts.Token); await Task.Delay(runForMs, CancellationToken.None); await loop.StopAsync(CancellationToken.None); } catch (OperationCanceledException) { } } [Fact] public async Task GeneratesCentralReports_WhenSelfIsPrimary() { var collector = new SiteHealthCollector(); var aggregator = new RecordingAggregator(); var clusterNodes = new FakeClusterNodeProvider { SelfIsPrimary = true }; var options = Options.Create(new HealthMonitoringOptions { ReportInterval = TimeSpan.FromMilliseconds(50) }); var loop = new CentralHealthReportLoop( collector, aggregator, clusterNodes, options, NullLogger.Instance); await RunLoopBriefly(loop, 250); Assert.NotEmpty(aggregator.Processed); Assert.All(aggregator.Processed, r => Assert.Equal(CentralHealthReportLoop.CentralSiteId, r.SiteId)); } [Fact] public async Task GeneratesNoReports_WhenNotPrimary() { var collector = new SiteHealthCollector(); var aggregator = new RecordingAggregator(); var clusterNodes = new FakeClusterNodeProvider { SelfIsPrimary = false }; var options = Options.Create(new HealthMonitoringOptions { ReportInterval = TimeSpan.FromMilliseconds(50) }); var loop = new CentralHealthReportLoop( collector, aggregator, clusterNodes, options, NullLogger.Instance); await RunLoopBriefly(loop, 250); Assert.Empty(aggregator.Processed); } [Fact] public async Task AssignsMonotonicSequenceNumbers() { var collector = new SiteHealthCollector(); var aggregator = new RecordingAggregator(); var clusterNodes = new FakeClusterNodeProvider { SelfIsPrimary = true }; var options = Options.Create(new HealthMonitoringOptions { ReportInterval = TimeSpan.FromMilliseconds(50) }); var loop = new CentralHealthReportLoop( collector, aggregator, clusterNodes, options, NullLogger.Instance); await RunLoopBriefly(loop, 300); Assert.True(aggregator.Processed.Count >= 2, $"Expected at least 2 reports, got {aggregator.Processed.Count}"); for (int i = 1; i < aggregator.Processed.Count; i++) { Assert.True( aggregator.Processed[i].SequenceNumber > aggregator.Processed[i - 1].SequenceNumber, $"Sequence numbers not strictly increasing at index {i}"); } } /// /// HealthMonitoring-006 regression: the central loop's sequence-number seed /// must be derived from the injected (Unix-ms), /// not from DateTimeOffset.UtcNow read at field initialization, so the /// seeding strategy is deterministically testable. /// [Fact] public void SequenceNumberSeed_UsesInjectedTimeProvider() { var fixedInstant = new DateTimeOffset(2026, 5, 16, 12, 0, 0, TimeSpan.Zero); var timeProvider = new TestTimeProvider(fixedInstant); var loop = new CentralHealthReportLoop( new SiteHealthCollector(), new RecordingAggregator(), new FakeClusterNodeProvider { SelfIsPrimary = true }, Options.Create(new HealthMonitoringOptions()), NullLogger.Instance, timeProvider); Assert.Equal(fixedInstant.ToUnixTimeMilliseconds(), loop.CurrentSequenceNumber); } [Fact] public async Task SetsActiveNodeFlag_EvenWhenNotPrimary() { // The loop must still report the node's role to the collector when it is // the standby, so the standby's own node card shows the correct role. var collector = new SiteHealthCollector(); var aggregator = new RecordingAggregator(); var clusterNodes = new FakeClusterNodeProvider { SelfIsPrimary = false }; var options = Options.Create(new HealthMonitoringOptions { ReportInterval = TimeSpan.FromMilliseconds(50) }); var loop = new CentralHealthReportLoop( collector, aggregator, clusterNodes, options, NullLogger.Instance); await RunLoopBriefly(loop, 150); Assert.False(collector.IsActiveNode); } }