using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; 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.ConfigurationDatabase; using ScadaLink.ConfigurationDatabase.Repositories; 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); } // ───────────────────────────────────────────────────────────────────────── // #23 M7 — DbContext concurrency race regression (Bundle H follow-up) // // The drill-in deep link (/audit/log?correlationId=…) triggers an OnInitialized // auto-load query that races AuditFilterBar.GetAllSitesAsync() on the SAME // scoped Blazor-circuit ScadaLinkDbContext. EF Core then throws // "A second operation was started on this context instance before a previous // operation completed." AuditLogQueryService now opens its OWN DI scope per // QueryAsync call (scope-per-query) so it never shares the page's scoped // context — these tests pin that contract. // ───────────────────────────────────────────────────────────────────────── [Fact] public async Task AuditLogQueryService_ConcurrentQueries_DoNotRace() { // A real ScadaLinkDbContext (SQLite in-memory) registered as SCOPED with // the real AuditLogRepository — exactly the shared-scoped-context shape // that produces the EF race when one context services two operations. using var connection = new Microsoft.Data.Sqlite.SqliteConnection("DataSource=:memory:"); connection.Open(); var services = new ServiceCollection(); services.AddLogging(); services.AddDbContext(options => options.UseSqlite(connection) .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))); services.AddScoped(); await using var provider = services.BuildServiceProvider(); // Create the schema once on a throwaway scope. using (var seedScope = provider.CreateScope()) { var ctx = seedScope.ServiceProvider.GetRequiredService(); await ctx.Database.EnsureCreatedAsync(); } var scopeFactory = provider.GetRequiredService(); var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator()); var filter = new AuditLogQueryFilter(Channel: AuditChannel.ApiOutbound); // Fire two QueryAsync calls in parallel. With scope-per-query each gets a // fresh DbContext, so this completes cleanly; with a shared scoped context // EF throws "A second operation was started on this context instance". var t1 = sut.QueryAsync(filter); var t2 = sut.QueryAsync(filter); var results = await Task.WhenAll(t1, t2); Assert.All(results, Assert.NotNull); } [Fact] public async Task QueryAsync_OpensFreshScopePerCall_NotSharedAcrossCalls() { // Two sequential calls must each resolve the repository from a distinct // scope — the service must never cache a single repository instance. var resolvedRepos = new List(); var services = new ServiceCollection(); services.AddScoped(_ => { var repo = Substitute.For(); repo.QueryAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult>(Array.Empty())); resolvedRepos.Add(repo); return repo; }); await using var provider = services.BuildServiceProvider(); var sut = new AuditLogQueryService( provider.GetRequiredService(), EmptyAggregator()); await sut.QueryAsync(new AuditLogQueryFilter()); await sut.QueryAsync(new AuditLogQueryFilter()); // Each QueryAsync opened its own scope → two distinct repo instances. Assert.Equal(2, resolvedRepos.Count); Assert.NotSame(resolvedRepos[0], resolvedRepos[1]); } 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, }; } }