using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using MySqlConnector; using Xunit; using Xunit.Abstractions; namespace JobTrackerApi.MariaDb.Tests; public sealed class StartupAndLockTests(ITestOutputHelper output) { [Fact] public async Task Full_startup_creates_every_table_and_column_required_by_the_persisted_EF_model() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); await using var connection = new MySqlConnection(database.ConnectionString); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE();"; await using var reader = await command.ExecuteReaderAsync(); var actual = new HashSet(StringComparer.OrdinalIgnoreCase); while (await reader.ReadAsync()) actual.Add($"{reader.GetString(0)}.{reader.GetString(1)}"); var missing = db.Model.GetRelationalModel().Tables .SelectMany(table => table.Columns.Select(column => $"{table.Name}.{column.Name}")) .Where(column => !actual.Contains(column)).Order().ToArray(); output.WriteLine($"Server: {database.ActualVersion}; actual columns: {actual.Count}; missing ({missing.Length}): {string.Join(", ", missing)}"); Assert.True(missing.Length == 0, $"Startup omitted persisted EF columns: {string.Join(", ", missing)}"); } [Fact] public async Task Fresh_repeated_and_populated_startup_preserve_owned_data_and_complete_migrations() { await using var database = await MariaDbDatabase.CreateAsync(); await using (var fresh = database.Context()) { Assert.Equal("Pomelo.EntityFrameworkCore.MySql", fresh.Database.ProviderName); Assert.Empty(await fresh.Database.GetPendingMigrationsAsync()); var applied = (await fresh.Database.GetAppliedMigrationsAsync()).ToArray(); Assert.Contains("20261023090000_AddJobRevision", applied); output.WriteLine($"MariaDB server: {database.ActualVersion}; EF: {typeof(DbContext).Assembly.GetName().Version}; Pomelo: {typeof(MariaDbServerVersion).Assembly.GetName().Version}"); output.WriteLine($"Applied migrations ({applied.Length}): {string.Join(", ", applied)}"); fresh.Users.AddRange( new ApplicationUser { Id = "rd21-owner", UserName = "owner@example.test", UiLanguage = "nb-NO" }, new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" }); fresh.Companies.AddRange(new Company { OwnerUserId = "rd21-owner", Name = "Owned" }, new Company { OwnerUserId = "other", Name = "Private" }); await fresh.SaveChangesAsync(); } await database.App.InitializeJobTrackerAsync(); await database.App.InitializeJobTrackerAsync(); await using var verify = database.Context(); Assert.Equal(new[] { "other", "rd21-owner" }, await verify.Users.AsNoTracking().OrderBy(x => x.Id).Select(x => x.Id).ToArrayAsync()); Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "rd21-owner")).UiLanguage); Assert.Equal("Owned", (await verify.Companies.AsNoTracking().SingleAsync()).Name); Assert.Equal(2, await verify.Companies.IgnoreQueryFilters().CountAsync()); Assert.Empty(await verify.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Rd21_additive_migration_preserves_existing_job_fields_and_backfills_revision_one() { await using var database = await MariaDbDatabase.CreateAsync(); await using var db = database.Context(); var company = new Company { OwnerUserId = "rd21-owner", Name = "Preserve me" }; db.Add(company); await db.SaveChangesAsync(); var job = new JobApplication { OwnerUserId = "rd21-owner", CompanyId = company.Id, JobTitle = "Historical title", Notes = "Historical notes", Status = "Prospect", CoverLetterText = "Historical letter", TailoredCvText = "Historical CV", }; job.Job = JobOpportunitySync.Create(job); db.Add(job); await db.SaveChangesAsync(); var migrations = (await db.Database.GetAppliedMigrationsAsync()).ToArray(); Assert.Equal("20261023090000_AddJobRevision", migrations[^1]); // Only this test's newly-created schema is downgraded; no existing database is selected. await db.GetService().MigrateAsync(migrations[^2]); Assert.Equal(new[] { migrations[^1] }, await db.Database.GetPendingMigrationsAsync()); db.ChangeTracker.Clear(); await database.App.InitializeJobTrackerAsync(); await using var verify = database.Context(); var actual = await verify.JobApplications.AsNoTracking().SingleAsync(); Assert.Equal(job.Id, actual.Id); Assert.Equal(job.CompanyId, actual.CompanyId); Assert.Equal("Historical title", actual.JobTitle); Assert.Equal("Historical notes", actual.Notes); Assert.Equal("Prospect", actual.Status); Assert.Equal("Historical letter", actual.CoverLetterText); Assert.Equal("Historical CV", actual.TailoredCvText); Assert.Equal("rd21-owner", actual.OwnerUserId); Assert.Equal(1, actual.Version); Assert.Equal(migrations, await verify.Database.GetAppliedMigrationsAsync()); Assert.Empty(await verify.Database.GetPendingMigrationsAsync()); } [Fact] public async Task Open_reader_does_not_block_independent_usage_and_identity_writes() { await using var database = await MariaDbDatabase.CreateAsync(); await using (var seed = database.Context()) { seed.Users.AddRange(new ApplicationUser { Id = "rd21-owner", UserName = "owner@example.test", UiLanguage = "en" }, new ApplicationUser { Id = "other", UserName = "other@example.test", UiLanguage = "en" }); seed.Companies.AddRange(new Company { OwnerUserId = "rd21-owner", Name = "Owned" }, new Company { OwnerUserId = "other", Name = "Other" }); await seed.SaveChangesAsync(); } await using var connection = new MySqlConnection(database.ConnectionString); await connection.OpenAsync(); await using var snapshot = await connection.BeginTransactionAsync(System.Data.IsolationLevel.RepeatableRead); await using var command = connection.CreateCommand(); command.Transaction = snapshot; 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("rd21-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("rd21-owner"))!; owner.UiLanguage = "nb-NO"; Assert.True((await users.UpdateAsync(owner)).Succeeded); await using var verify = database.Context(); var usage = await verify.AiUsageRecords.AsNoTracking().SingleAsync(); Assert.Equal(reservation.Record.Id, usage.Id); Assert.Equal("held-reader", usage.SourceId); Assert.Equal(1, usage.CallCount); Assert.Equal("nb-NO", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "rd21-owner")).UiLanguage); Assert.Equal("en", (await verify.Users.AsNoTracking().SingleAsync(x => x.Id == "other")).UiLanguage); Assert.True(await reader.ReadAsync()); Assert.False(await reader.ReadAsync()); } [Theory] [InlineData(null)] [InlineData("Server=database.example.test;User ID=test;Password=synthetic")] [InlineData("Server=192.0.2.1;Database=development;User ID=test")] public void Unsafe_or_missing_admin_configuration_fails_closed(string? connection) => Assert.Throws(() => MariaDbDatabase.ValidateAdminConnection(connection)); [Fact] public void Admin_database_name_is_never_used_as_the_test_database() { var connection = MariaDbDatabase.ValidateAdminConnection("Server=127.0.0.1;Database=development;User ID=test"); Assert.Equal("", connection.Database); Assert.False(connection.Pooling); } }