using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Moq; using Xunit; namespace JobTrackerApi.Tests; public sealed class SqliteStartupConcurrencyTests { [Fact] public async Task Fresh_and_populated_startup_enable_WAL_without_changing_owned_rows() { await using var database = await Database.CreateAsync(); await using var connection = new SqliteConnection(database.ConnectionString); await connection.OpenAsync(); await using var journal = connection.CreateCommand(); journal.CommandText = "PRAGMA journal_mode;"; Assert.Equal("wal", await journal.ExecuteScalarAsync()); journal.CommandText = "PRAGMA journal_mode=DELETE;"; Assert.Equal("delete", await journal.ExecuteScalarAsync()); await connection.CloseAsync(); await database.App.InitializeJobTrackerAsync(); await connection.OpenAsync(); journal.CommandText = "PRAGMA journal_mode;"; Assert.Equal("wal", await journal.ExecuteScalarAsync()); await using var scope = database.App.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); Assert.Equal(new[] { "other", "owner" }, await db.Users.OrderBy(user => user.Id).Select(user => user.Id).ToArrayAsync()); Assert.Equal(new[] { "Other company", "Owned company" }, await db.Companies.IgnoreQueryFilters().OrderBy(company => company.Name).Select(company => company.Name).ToArrayAsync()); Assert.All(await db.Users.ToListAsync(), user => Assert.Equal("en", user.UiLanguage)); Assert.Empty(await db.Database.GetPendingMigrationsAsync()); } [Fact] public async Task An_open_read_cursor_does_not_block_usage_reservation_or_Identity_preference_save() { await using var database = await Database.CreateAsync(); await using var connection = new SqliteConnection(database.ConnectionString); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT Id FROM Companies ORDER BY Id;"; await using var reader = await command.ExecuteReaderAsync(); Assert.True(await reader.ReadAsync()); await using var scope = database.App.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var reservation = await new AiUsageMeter(db, TimeProvider.System).ReserveAsync( "owner", new AccountEntitlements(true, true, 1_000_000, 10, 10_000), "synchronous", "held-reader", "synchronous.summary", 40, 10, default); Assert.True(reservation.Created); var users = scope.ServiceProvider.GetRequiredService>(); var owner = await users.FindByIdAsync("owner"); Assert.NotNull(owner); owner.UiLanguage = "nb-NO"; Assert.True((await users.UpdateAsync(owner)).Succeeded); await using var verifyScope = database.App.Services.CreateAsyncScope(); var verify = verifyScope.ServiceProvider.GetRequiredService(); var usage = Assert.Single(await verify.AiUsageRecords.AsNoTracking().ToListAsync()); Assert.Equal(reservation.Record.Id, usage.Id); Assert.Equal("owner", usage.OwnerUserId); Assert.Equal("held-reader", usage.SourceId); Assert.Equal(1, usage.CallCount); Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(user => user.Id == "owner")).UiLanguage); Assert.Equal("en", (await verify.Users.AsNoTracking().SingleAsync(user => user.Id == "other")).UiLanguage); Assert.Equal(2, await verify.Companies.IgnoreQueryFilters().CountAsync()); Assert.True(await reader.ReadAsync()); Assert.False(await reader.ReadAsync()); } private sealed class Database(WebApplication app, string root, string connectionString) : IAsyncDisposable { public WebApplication App { get; } = app; public string ConnectionString { get; } = connectionString; public static async Task CreateAsync() { var root = Path.Combine(Path.GetTempPath(), $"jobtracker-startup-lock-{Guid.NewGuid():N}"); var connectionString = $"Data Source={Path.Combine(root, "jobtracker.db")};Default Timeout=1;Pooling=False"; var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production", ApplicationName = typeof(StartupInitializationExtensions).Assembly.GetName().Name, }); builder.Configuration.AddInMemoryCollection(new Dictionary { ["Data:Root"] = root, ["Database:Provider"] = "sqlite", ["ConnectionStrings:JobTracker"] = connectionString, ["Auth:AdminEmail"] = "", ["Auth:AdminPassword"] = "", }); builder.Logging.ClearProviders(); var currentUser = new Mock(); currentUser.SetupGet(service => service.UserId).Returns("owner"); builder.Services.AddSingleton(currentUser.Object); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString) .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning))); builder.Services.AddIdentityCore().AddRoles().AddEntityFrameworkStores(); var app = builder.Build(); var database = new Database(app, root, connectionString); try { await app.InitializeJobTrackerAsync(); await using var scope = app.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); db.Users.AddRange( new ApplicationUser { Id = "owner", UserName = "owner@example.test", UiLanguage = "en" }, new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" }); db.Companies.AddRange( new Company { Name = "Owned company", OwnerUserId = "owner" }, new Company { Name = "Other company", OwnerUserId = "other" }); await db.SaveChangesAsync(); return database; } catch { await database.DisposeAsync(); throw; } } public async ValueTask DisposeAsync() { await App.DisposeAsync(); var fullRoot = Path.GetFullPath(root); Assert.StartsWith(Path.GetFullPath(Path.GetTempPath()), fullRoot, StringComparison.OrdinalIgnoreCase); Assert.StartsWith("jobtracker-startup-lock-", Path.GetFileName(fullRoot), StringComparison.Ordinal); if (Directory.Exists(fullRoot)) Directory.Delete(fullRoot, recursive: true); } } }