using JobTrackerApi.Controllers; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class ConcurrentInitializationTests { [Fact] public async Task Concurrent_rule_reads_share_the_single_created_settings_row() { await using var database = await Database.CreateAsync(); await using var first = database.CreateContext(new SaveGate(delay: false)); await using var second = database.CreateContext(new SaveGate(delay: true)); var results = await Task.WhenAll( new RulesController(first).Get(default), new RulesController(second).Get(default)); Assert.All(results, result => Assert.NotNull(result.Result)); await using var verify = database.CreateContext(); Assert.Single(await verify.UserRuleSettings.ToListAsync()); } [Fact] public async Task Concurrent_checklist_reads_share_the_single_default_item_set() { await using var database = await Database.CreateAsync(); int jobId; await using (var setup = database.CreateContext()) { var company = new Company { OwnerUserId = Database.Owner, Name = "Acme" }; setup.Companies.Add(company); await setup.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = Database.Owner, CompanyId = company.Id, JobTitle = "Backend Developer", Status = "Applied", Description = "Needs .NET and SQL.", }; setup.JobApplications.Add(job); await setup.SaveChangesAsync(); jobId = job.Id; } await using var first = database.CreateContext(new SaveGate(delay: false)); await using var second = database.CreateContext(new SaveGate(delay: true)); var results = await Task.WhenAll( new ApplicationChecklistService(first).GetAsync(Database.Owner, jobId, default), new ApplicationChecklistService(second).GetAsync(Database.Owner, jobId, default)); var expectedCount = results[0]!.Items.Count; Assert.Equal(expectedCount, results[1]!.Items.Count); await using var verify = database.CreateContext(); Assert.Equal(expectedCount, await verify.ApplicationChecklistItems.CountAsync()); } private sealed class SaveGate(bool delay) : SaveChangesInterceptor { private static readonly object Sync = new(); private static TaskCompletionSource _bothArrived = NewSignal(); private static int _arrivals; private int _used; public override async ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { if (Interlocked.Exchange(ref _used, 1) != 0) return result; Task wait; lock (Sync) { if (_arrivals == 0 && _bothArrived.Task.IsCompleted) _bothArrived = NewSignal(); _arrivals++; if (_arrivals == 2) { _arrivals = 0; _bothArrived.TrySetResult(true); } wait = _bothArrived.Task; } await wait.WaitAsync(cancellationToken); if (delay) await Task.Delay(100, cancellationToken); return result; } private static TaskCompletionSource NewSignal() => new(TaskCreationOptions.RunContinuationsAsynchronously); } private sealed class Database : IAsyncDisposable { public const string Owner = "user-1"; private readonly string _path; private readonly DbContextOptions _setupOptions; private readonly ICurrentUserService _currentUser; private Database(string path) { _path = path; _setupOptions = new DbContextOptionsBuilder() .UseSqlite($"Data Source={path}") .Options; var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns(Owner); _currentUser = currentUser.Object; } public static async Task CreateAsync() { var database = new Database(Path.Combine(Path.GetTempPath(), $"jobtracker-concurrent-{Guid.NewGuid():N}.db")); await using var db = new JobTrackerContext(database._setupOptions, database._currentUser); await db.Database.EnsureCreatedAsync(); return database; } public JobTrackerContext CreateContext(SaveChangesInterceptor? interceptor = null) { var builder = new DbContextOptionsBuilder().UseSqlite($"Data Source={_path}"); if (interceptor is not null) builder.AddInterceptors(interceptor); return new JobTrackerContext(builder.Options, _currentUser); } public ValueTask DisposeAsync() { SqliteConnection.ClearAllPools(); File.Delete(_path); return ValueTask.CompletedTask; } } }