fix(ui): AuditLogQueryService uses scope-per-query to avoid DbContext race (#23 M7)

This commit is contained in:
Joseph Doherty
2026-05-20 21:33:38 -04:00
parent 9c955da2e7
commit fac31c6018
5 changed files with 190 additions and 16 deletions

View File

@@ -1,3 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ScadaLink.CentralUI.Services;
using ScadaLink.Commons.Entities.Audit;
@@ -6,6 +9,8 @@ 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;
@@ -134,6 +139,89 @@ public class AuditLogQueryServiceTests
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(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<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]);
}
private static SiteHealthState StateWithBacklog(string siteId, int? pending)
{
SiteAuditBacklogSnapshot? backlog = pending.HasValue