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,
};
}
}