315 lines
14 KiB
C#
315 lines
14 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Service-level tests for <see cref="AuditLogQueryService"/> (#23 M7-T3). The
|
|
/// service is a thin pass-through over <see cref="IAuditLogRepository.QueryAsync"/>;
|
|
/// these tests pin the filter forwarding contract and the 100-row default-page-size
|
|
/// rule the grid relies on.
|
|
/// </summary>
|
|
public class AuditLogQueryServiceTests
|
|
{
|
|
private static ICentralHealthAggregator EmptyAggregator()
|
|
{
|
|
var agg = Substitute.For<ICentralHealthAggregator>();
|
|
agg.GetAllSiteStates().Returns(new Dictionary<string, SiteHealthState>());
|
|
return agg;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_ForwardsFilterAndPaging_ToRepository()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
var filter = new AuditLogQueryFilter(Channels: new[] { AuditChannel.ApiOutbound });
|
|
var paging = new AuditLogPaging(PageSize: 25);
|
|
var expected = new List<AuditEvent>
|
|
{
|
|
new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTime.UtcNow, Channel = AuditChannel.ApiOutbound, Kind = AuditKind.ApiCall, Status = AuditStatus.Delivered }
|
|
};
|
|
repo.QueryAsync(filter, paging, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(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<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task QueryAsync_AppliesDefaultPageSize_WhenNotSpecified()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
AuditLogPaging? observed = null;
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Do<AuditLogPaging>(p => observed = p), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
|
|
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<IAuditLogRepository>();
|
|
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<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
|
.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<string, SiteHealthState>
|
|
{
|
|
["plant-a"] = StateWithBacklog("plant-a", pending: 5),
|
|
["plant-b"] = StateWithBacklog("plant-b", pending: 11),
|
|
};
|
|
var agg = Substitute.For<ICentralHealthAggregator>();
|
|
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<DateTime?>(v => v == null),
|
|
Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetKpiSnapshotAsync_SiteWithoutBacklogSnapshot_ContributesZero()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.GetKpiSnapshotAsync(Arg.Any<TimeSpan>(), Arg.Any<DateTime?>(), Arg.Any<CancellationToken>())
|
|
.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<string, SiteHealthState>
|
|
{
|
|
["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<ICentralHealthAggregator>();
|
|
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<ScadaLinkDbContext>(options =>
|
|
options.UseSqlite(connection)
|
|
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));
|
|
services.AddScoped<IAuditLogRepository, AuditLogRepository>();
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
|
|
// Create the schema once on a throwaway scope.
|
|
using (var seedScope = provider.CreateScope())
|
|
{
|
|
var ctx = seedScope.ServiceProvider.GetRequiredService<ScadaLinkDbContext>();
|
|
await ctx.Database.EnsureCreatedAsync();
|
|
}
|
|
|
|
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
|
|
var sut = new AuditLogQueryService(scopeFactory, EmptyAggregator());
|
|
|
|
var filter = new AuditLogQueryFilter(Channels: new[] { 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<IAuditLogRepository>();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<IAuditLogRepository>(_ =>
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.QueryAsync(Arg.Any<AuditLogQueryFilter>(), Arg.Any<AuditLogPaging>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<AuditEvent>>(Array.Empty<AuditEvent>()));
|
|
resolvedRepos.Add(repo);
|
|
return repo;
|
|
});
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
var sut = new AuditLogQueryService(
|
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
|
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]);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Audit Log ParentExecutionId feature (Task 10): GetExecutionTreeAsync —
|
|
// a thin pass-through over IAuditLogRepository.GetExecutionTreeAsync, mirroring
|
|
// QueryAsync's scope-per-call contract on the production path.
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public async Task GetExecutionTreeAsync_ForwardsExecutionId_ToRepository()
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
var executionId = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
|
|
var expected = new List<ExecutionTreeNode>
|
|
{
|
|
new(executionId, null, 3,
|
|
new[] { "ApiOutbound" }, new[] { "Delivered" },
|
|
"plant-a", "boiler-3",
|
|
new DateTime(2026, 5, 20, 12, 0, 0, DateTimeKind.Utc),
|
|
new DateTime(2026, 5, 20, 12, 0, 5, DateTimeKind.Utc)),
|
|
};
|
|
repo.GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(expected));
|
|
|
|
var sut = new AuditLogQueryService(repo, EmptyAggregator());
|
|
|
|
var result = await sut.GetExecutionTreeAsync(executionId);
|
|
|
|
Assert.Same(expected, result);
|
|
await repo.Received(1).GetExecutionTreeAsync(executionId, Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetExecutionTreeAsync_OpensFreshScopePerCall_OnProductionCtor()
|
|
{
|
|
// The production ctor must resolve a fresh repository per call — same
|
|
// scope-per-query contract QueryAsync upholds, so the page's auto-load
|
|
// never shares the circuit-scoped DbContext.
|
|
var resolvedRepos = new List<IAuditLogRepository>();
|
|
|
|
var services = new ServiceCollection();
|
|
services.AddScoped<IAuditLogRepository>(_ =>
|
|
{
|
|
var repo = Substitute.For<IAuditLogRepository>();
|
|
repo.GetExecutionTreeAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
|
.Returns(Task.FromResult<IReadOnlyList<ExecutionTreeNode>>(Array.Empty<ExecutionTreeNode>()));
|
|
resolvedRepos.Add(repo);
|
|
return repo;
|
|
});
|
|
|
|
await using var provider = services.BuildServiceProvider();
|
|
var sut = new AuditLogQueryService(
|
|
provider.GetRequiredService<IServiceScopeFactory>(),
|
|
EmptyAggregator());
|
|
|
|
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
|
await sut.GetExecutionTreeAsync(Guid.NewGuid());
|
|
|
|
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<string, ConnectionHealth>(),
|
|
TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
|
|
ScriptErrorCount: 0,
|
|
AlarmEvaluationErrorCount: 0,
|
|
StoreAndForwardBufferDepths: new Dictionary<string, int>(),
|
|
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,
|
|
};
|
|
}
|
|
}
|