using NSubstitute; using ScadaLink.CentralUI.Services; using ScadaLink.Commons.Entities.Audit; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Messages.Health; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Audit; using ScadaLink.Commons.Types.Enums; using ScadaLink.HealthMonitoring; namespace ScadaLink.CentralUI.Tests.Services; /// /// Service-level tests for (#23 M7-T3). The /// service is a thin pass-through over ; /// these tests pin the filter forwarding contract and the 100-row default-page-size /// rule the grid relies on. /// public class AuditLogQueryServiceTests { private static ICentralHealthAggregator EmptyAggregator() { var agg = Substitute.For(); agg.GetAllSiteStates().Returns(new Dictionary()); return agg; } [Fact] public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository() { var repo = Substitute.For(); var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound); var paging = new AuditLogPaging(PageSize: 25); var expected = new List { new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered } }; repo.QueryAsync(filter, paging, Arg.Any()) .Returns(Task.FromResult>(expected)); var sut = new AuditLogQueryService(repo, EmptyAggregator()); var result = await sut.QueryAsync(filter, paging); Assert.Same(expected, result); await repo.Received(1).QueryAsync(filter, paging, Arg.Any()); } [Fact] public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified() { var repo = Substitute.For(); AuditLogPaging? observed = null; repo.QueryAsync(Arg.Any(), Arg.Do(p => observed = p), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); var sut = new AuditLogQueryService(repo, EmptyAggregator()); await sut.QueryAsync(new AuditLogQueryFilter(), paging: null); Assert.NotNull(observed); Assert.Equal(sut.DefaultPageSize, observed!.PageSize); Assert.Equal(100, sut.DefaultPageSize); Assert.Null(observed.AfterOccurredAtUtc); Assert.Null(observed.AfterEventId); } // ───────────────────────────────────────────────────────────────────────── // M7-T13 Bundle E: GetKpiSnapshotAsync — composes repo + health-aggregator // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task GetKpiSnapshotAsync_ForwardsToRepo_AddsBacklogFromHealthAggregator() { var repo = Substitute.For(); var anchor = new DateTime(2026, 5, 20, 10, 0, 0, DateTimeKind.Utc); var repoSnapshot = new AuditLogKpiSnapshot( TotalEventsLastHour: 42, ErrorEventsLastHour: 7, BacklogTotal: 0, // repo leaves this at zero AsOfUtc: anchor); repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(repoSnapshot)); // Two sites: plant-a with PendingCount=5, plant-b with PendingCount=11. // Sum = 16 → backlog tile shows 16. var sites = new Dictionary { ["plant-a"] = StateWithBacklog("plant-a", pending: 5), ["plant-b"] = StateWithBacklog("plant-b", pending: 11), }; var agg = Substitute.For(); agg.GetAllSiteStates().Returns(sites); var sut = new AuditLogQueryService(repo, agg); var snapshot = await sut.GetKpiSnapshotAsync(); Assert.Equal(42, snapshot.TotalEventsLastHour); Assert.Equal(7, snapshot.ErrorEventsLastHour); Assert.Equal(16, snapshot.BacklogTotal); Assert.Equal(anchor, snapshot.AsOfUtc); // The service requests a 1-hour trailing window and lets the repo // anchor nowUtc to its own clock — we leave the second parameter null. await repo.Received(1).GetKpiSnapshotAsync( TimeSpan.FromHours(1), Arg.Is(v => v == null), Arg.Any()); } [Fact] public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero() { var repo = Substitute.For(); repo.GetKpiSnapshotAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new AuditLogKpiSnapshot(0, 0, 0, DateTime.UtcNow))); // plant-a has no LatestReport at all; plant-b has a report but null SiteAuditBacklog. var sites = new Dictionary { ["plant-a"] = new() { SiteId = "plant-a", LatestReport = null, IsOnline = true }, ["plant-b"] = StateWithBacklog("plant-b", pending: null), ["plant-c"] = StateWithBacklog("plant-c", pending: 4), }; var agg = Substitute.For(); agg.GetAllSiteStates().Returns(sites); var sut = new AuditLogQueryService(repo, agg); var snapshot = await sut.GetKpiSnapshotAsync(); // Only plant-c contributes; plant-a (no report) and plant-b (null backlog) yield zero. Assert.Equal(4, snapshot.BacklogTotal); } private static SiteHealthState StateWithBacklog(string siteId, int? pending) { SiteAuditBacklogSnapshot? backlog = pending.HasValue ? new SiteAuditBacklogSnapshot(pending.Value, OldestPendingUtc: null, OnDiskBytes: 0) : null; var report = new SiteHealthReport( SiteId: siteId, SequenceNumber: 1, ReportTimestamp: DateTimeOffset.UtcNow, DataConnectionStatuses: new Dictionary(), TagResolutionCounts: new Dictionary(), ScriptErrorCount: 0, AlarmEvaluationErrorCount: 0, StoreAndForwardBufferDepths: new Dictionary(), DeadLetterCount: 0, DeployedInstanceCount: 0, EnabledInstanceCount: 0, DisabledInstanceCount: 0, SiteAuditBacklog: backlog); return new SiteHealthState { SiteId = siteId, LatestReport = report, LastReportReceivedAt = DateTimeOffset.UtcNow, LastHeartbeatAt = DateTimeOffset.UtcNow, LastSequenceNumber = 1, IsOnline = true, }; } }