Files
jobtrackingapp/JobTrackerApi.MariaDb.Tests/StartupAndLockTests.cs
T

158 lines
8.9 KiB
C#

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<string>(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<IMigrator>().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<JobTrackerContext>();
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<UserManager<ApplicationUser>>();
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<InvalidOperationException>(() => 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);
}
}