diff --git a/src/TrueCV.Infrastructure/DependencyInjection.cs b/src/TrueCV.Infrastructure/DependencyInjection.cs index eaa3943..af33a34 100644 --- a/src/TrueCV.Infrastructure/DependencyInjection.cs +++ b/src/TrueCV.Infrastructure/DependencyInjection.cs @@ -19,6 +19,19 @@ public static class DependencyInjection public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration) { // Configure DbContext with SQL Server + // AddDbContextFactory enables thread-safe parallel operations + services.AddDbContextFactory(options => + options.UseSqlServer( + configuration.GetConnectionString("DefaultConnection"), + sqlOptions => + { + sqlOptions.EnableRetryOnFailure( + maxRetryCount: 3, + maxRetryDelay: TimeSpan.FromSeconds(30), + errorNumbersToAdd: null); + })); + + // Also register DbContext for scoped injection (non-parallel scenarios) services.AddDbContext(options => options.UseSqlServer( configuration.GetConnectionString("DefaultConnection"), diff --git a/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs b/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs index 59017cc..e26dab0 100644 --- a/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs +++ b/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs @@ -14,7 +14,7 @@ namespace TrueCV.Infrastructure.Services; public sealed class CompanyVerifierService : ICompanyVerifierService { private readonly CompaniesHouseClient _companiesHouseClient; - private readonly ApplicationDbContext _dbContext; + private readonly IDbContextFactory _dbContextFactory; private readonly ILogger _logger; private const int FuzzyMatchThreshold = 70; @@ -22,11 +22,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService public CompanyVerifierService( CompaniesHouseClient companiesHouseClient, - ApplicationDbContext dbContext, + IDbContextFactory dbContextFactory, ILogger logger) { _companiesHouseClient = companiesHouseClient; - _dbContext = dbContext; + _dbContextFactory = dbContextFactory; _logger = logger; } @@ -123,8 +123,11 @@ public sealed class CompanyVerifierService : ICompanyVerifierService { var cutoffDate = DateTime.UtcNow.AddDays(-CacheExpirationDays); + // Use factory to create a new DbContext for thread-safe parallel access + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + // Get recent cached companies - var cachedCompanies = await _dbContext.CompanyCache + var cachedCompanies = await dbContext.CompanyCache .Where(c => c.CachedAt >= cutoffDate) .ToListAsync(); @@ -160,7 +163,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService private async Task CacheCompanyAsync(CompaniesHouseSearchItem item) { - var existingCache = await _dbContext.CompanyCache + // Use factory to create a new DbContext for thread-safe parallel access + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(); + + var existingCache = await dbContext.CompanyCache .FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber); if (existingCache is not null) @@ -183,10 +189,10 @@ public sealed class CompanyVerifierService : ICompanyVerifierService CachedAt = DateTime.UtcNow }; - _dbContext.CompanyCache.Add(cacheEntry); + dbContext.CompanyCache.Add(cacheEntry); } - await _dbContext.SaveChangesAsync(); + await dbContext.SaveChangesAsync(); } private static CompanyVerificationResult CreateVerificationResult( diff --git a/tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs b/tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs index e7998bf..c06e392 100644 --- a/tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs +++ b/tests/TrueCV.Tests/Services/CompanyVerifierServiceTests.cs @@ -3,6 +3,8 @@ using System.Net.Http.Json; using System.Text.Json; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; @@ -23,6 +25,8 @@ public class CompanyVerifierServiceTests : IDisposable private readonly ApplicationDbContext _dbContext; private readonly CompanyVerifierService _sut; private readonly HttpClient _httpClient; + private readonly DbContextOptions _dbOptions; + private readonly InMemoryDatabaseRoot _dbRoot; private static readonly JsonSerializerOptions JsonOptions = new() { @@ -46,13 +50,25 @@ public class CompanyVerifierServiceTests : IDisposable var client = new CompaniesHouseClient(_httpClient, settings, _mockClientLogger.Object); - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + // Use a shared InMemoryDatabaseRoot so all contexts share the same data store + _dbRoot = new InMemoryDatabaseRoot(); + var dbName = Guid.NewGuid().ToString(); + + _dbOptions = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: dbName, databaseRoot: _dbRoot) + .ConfigureWarnings(w => w.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) .Options; - _dbContext = new ApplicationDbContext(options); + _dbContext = new ApplicationDbContext(_dbOptions); - _sut = new CompanyVerifierService(client, _dbContext, _mockServiceLogger.Object); + // Create a mock factory that returns a new context with the same in-memory database + var mockFactory = new Mock>(); + mockFactory.Setup(f => f.CreateDbContext()) + .Returns(() => new ApplicationDbContext(_dbOptions)); + mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(() => new ApplicationDbContext(_dbOptions)); + + _sut = new CompanyVerifierService(client, mockFactory.Object, _mockServiceLogger.Object); } public void Dispose() @@ -313,8 +329,9 @@ public class CompanyVerifierServiceTests : IDisposable // Act await _sut.VerifyCompanyAsync(companyName, null, null); - // Assert + // Assert - use AsNoTracking to get fresh data from the store var cachedEntry = await _dbContext.CompanyCache + .AsNoTracking() .FirstOrDefaultAsync(c => c.CompanyNumber == "77777777"); cachedEntry.Should().NotBeNull(); @@ -348,8 +365,9 @@ public class CompanyVerifierServiceTests : IDisposable // Act await _sut.VerifyCompanyAsync(companyName, null, null); - // Assert + // Assert - use AsNoTracking to get fresh data from the store var cachedEntry = await _dbContext.CompanyCache + .AsNoTracking() .FirstOrDefaultAsync(c => c.CompanyNumber == "55555555"); cachedEntry.Should().NotBeNull();