using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using ScadaLink.Commons.Messages.Health; using ScadaLink.Commons.Types.Enums; namespace ScadaLink.HealthMonitoring.Tests; /// /// A simple fake TimeProvider for testing that allows advancing time manually. /// internal sealed class TestTimeProvider : TimeProvider { private DateTimeOffset _utcNow; public TestTimeProvider(DateTimeOffset startTime) { _utcNow = startTime; } public override DateTimeOffset GetUtcNow() => _utcNow; public void Advance(TimeSpan duration) => _utcNow += duration; } public class CentralHealthAggregatorTests { private readonly TestTimeProvider _timeProvider; private readonly CentralHealthAggregator _aggregator; public CentralHealthAggregatorTests() { _timeProvider = new TestTimeProvider(DateTimeOffset.UtcNow); var options = Options.Create(new HealthMonitoringOptions { OfflineTimeout = TimeSpan.FromSeconds(60) }); _aggregator = new CentralHealthAggregator( options, NullLogger.Instance, _timeProvider); } private static SiteHealthReport MakeReport(string siteId, long seq) => new( SiteId: siteId, SequenceNumber: seq, ReportTimestamp: DateTimeOffset.UtcNow, DataConnectionStatuses: new Dictionary(), TagResolutionCounts: new Dictionary(), ScriptErrorCount: 0, AlarmEvaluationErrorCount: 0, StoreAndForwardBufferDepths: new Dictionary(), DeadLetterCount: 0); [Fact] public void ProcessReport_StoresState_ForNewSite() { _aggregator.ProcessReport(MakeReport("site-1", 1)); var state = _aggregator.GetSiteState("site-1"); Assert.NotNull(state); Assert.True(state.IsOnline); Assert.Equal(1, state.LastSequenceNumber); } [Fact] public void ProcessReport_UpdatesState_WhenSequenceIncreases() { _aggregator.ProcessReport(MakeReport("site-1", 1)); _aggregator.ProcessReport(MakeReport("site-1", 2)); var state = _aggregator.GetSiteState("site-1"); Assert.Equal(2, state!.LastSequenceNumber); } [Fact] public void ProcessReport_RejectsStaleReport_WhenSequenceNotGreater() { _aggregator.ProcessReport(MakeReport("site-1", 5)); _aggregator.ProcessReport(MakeReport("site-1", 3)); var state = _aggregator.GetSiteState("site-1"); Assert.Equal(5, state!.LastSequenceNumber); } [Fact] public void ProcessReport_RejectsEqualSequence() { _aggregator.ProcessReport(MakeReport("site-1", 5)); _aggregator.ProcessReport(MakeReport("site-1", 5)); var state = _aggregator.GetSiteState("site-1"); Assert.Equal(5, state!.LastSequenceNumber); } [Fact] public void OfflineDetection_SiteGoesOffline_WhenNoReportWithinTimeout() { _aggregator.ProcessReport(MakeReport("site-1", 1)); Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); // Advance past the offline timeout _timeProvider.Advance(TimeSpan.FromSeconds(61)); _aggregator.CheckForOfflineSites(); Assert.False(_aggregator.GetSiteState("site-1")!.IsOnline); } [Fact] public void OnlineRecovery_SiteComesBackOnline_WhenReportReceived() { _aggregator.ProcessReport(MakeReport("site-1", 1)); // Go offline _timeProvider.Advance(TimeSpan.FromSeconds(61)); _aggregator.CheckForOfflineSites(); Assert.False(_aggregator.GetSiteState("site-1")!.IsOnline); // Receive new report → back online _aggregator.ProcessReport(MakeReport("site-1", 2)); Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); } [Fact] public void OfflineDetection_SiteRemainsOnline_WhenReportWithinTimeout() { _aggregator.ProcessReport(MakeReport("site-1", 1)); _timeProvider.Advance(TimeSpan.FromSeconds(30)); _aggregator.CheckForOfflineSites(); Assert.True(_aggregator.GetSiteState("site-1")!.IsOnline); } [Fact] public void GetAllSiteStates_ReturnsAllKnownSites() { _aggregator.ProcessReport(MakeReport("site-1", 1)); _aggregator.ProcessReport(MakeReport("site-2", 1)); var states = _aggregator.GetAllSiteStates(); Assert.Equal(2, states.Count); Assert.Contains("site-1", states.Keys); Assert.Contains("site-2", states.Keys); } [Fact] public void GetSiteState_ReturnsNull_ForUnknownSite() { var state = _aggregator.GetSiteState("nonexistent"); Assert.Null(state); } [Fact] public void ProcessReport_StoresLatestReport() { var report = MakeReport("site-1", 1) with { ScriptErrorCount = 42 }; _aggregator.ProcessReport(report); var state = _aggregator.GetSiteState("site-1"); Assert.Equal(42, state!.LatestReport.ScriptErrorCount); } [Fact] public void SequenceNumberReset_RejectedUntilExceedsPrevMax() { // Site sends seq 10, then restarts and sends seq 1. // Per design: sequence resets on singleton restart. // The aggregator will reject seq 1 < 10 — expected behavior. _aggregator.ProcessReport(MakeReport("site-1", 10)); _aggregator.ProcessReport(MakeReport("site-1", 1)); var state = _aggregator.GetSiteState("site-1"); Assert.Equal(10, state!.LastSequenceNumber); // Once it exceeds the old max, it works again _aggregator.ProcessReport(MakeReport("site-1", 11)); Assert.Equal(11, state.LastSequenceNumber); } }