fix(ui): AuditLogQueryService uses scope-per-query to avoid DbContext race (#23 M7)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user