using System.Net; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using MySqlConnector; using Moq; namespace JobTrackerApi.MariaDb.Tests; // Each test owns its schema. Never connect contexts to the administrator's selected database. internal sealed class MariaDbDatabase : IAsyncDisposable { private readonly string _adminConnection; private readonly string _root; private bool _created; public string Name { get; } = $"jt_it_{Guid.NewGuid():N}"; public string ConnectionString { get; private set; } = ""; public MariaDbServerVersion ServerVersion { get; private set; } = null!; public string ActualVersion { get; private set; } = ""; public WebApplication App { get; private set; } = null!; private MariaDbDatabase(string adminConnection) { _adminConnection = adminConnection; _root = Path.GetFullPath(Path.Combine(Path.GetTempPath(), Name)); } internal static MySqlConnectionStringBuilder ValidateAdminConnection(string? value) { if (string.IsNullOrWhiteSpace(value)) throw new InvalidOperationException("Explicit MariaDB test lane requires JOBTRACKER_MARIADB_TEST_ADMIN; no skip or SQLite fallback is allowed."); var builder = new MySqlConnectionStringBuilder(value); if (!(builder.Server.Equals("localhost", StringComparison.OrdinalIgnoreCase) || IPAddress.TryParse(builder.Server, out var address) && IPAddress.IsLoopback(address))) throw new InvalidOperationException("MariaDB integration tests require a loopback server."); if (builder.ConnectionProtocol != MySqlConnectionProtocol.Sockets) throw new InvalidOperationException("MariaDB integration tests require loopback TCP."); builder.Database = ""; builder.Pooling = false; builder.ConnectionTimeout = 5; builder.DefaultCommandTimeout = 30; return builder; } public static async Task CreateAsync() { var admin = ValidateAdminConnection(Environment.GetEnvironmentVariable("JOBTRACKER_MARIADB_TEST_ADMIN")); var database = new MariaDbDatabase(admin.ConnectionString); try { await using var connection = new MySqlConnection(admin.ConnectionString); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = "SELECT VERSION();"; database.ActualVersion = (string)(await command.ExecuteScalarAsync())!; var expected = Environment.GetEnvironmentVariable("JOBTRACKER_MARIADB_TEST_VERSION") ?? "12.2.2"; if (!Version.TryParse(expected, out var pin) || pin.Build < 0 || pin.Revision >= 0 || pin.Major < 11) throw new InvalidOperationException("JOBTRACKER_MARIADB_TEST_VERSION must pin a supported MariaDB major.minor.patch version."); if (!database.ActualVersion.StartsWith(expected + "-", StringComparison.Ordinal) || !database.ActualVersion.Contains("MariaDB", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"Expected MariaDB {expected}; actual server is {database.ActualVersion}."); database.ServerVersion = new MariaDbServerVersion(pin); command.CommandText = $"CREATE DATABASE `{database.Name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"; await command.ExecuteNonQueryAsync(); database._created = true; admin.Database = database.Name; database.ConnectionString = admin.ConnectionString; var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Production", ApplicationName = typeof(StartupInitializationExtensions).Assembly.GetName().Name, }); builder.Configuration.AddInMemoryCollection(new Dictionary { ["Data:Root"] = database._root, ["Database:Provider"] = "mariadb", ["ConnectionStrings:JobTracker"] = database.ConnectionString, ["Auth:AdminEmail"] = "", ["Auth:AdminPassword"] = "", }); builder.Logging.ClearProviders(); builder.Services.AddSingleton(Mock.Of(user => user.UserId == "rd21-owner")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddDbContext(options => database.Configure(options)); builder.Services.AddIdentityCore().AddRoles().AddEntityFrameworkStores(); database.App = builder.Build(); await database.App.InitializeJobTrackerAsync(); return database; } catch { await database.DisposeAsync(); throw; } } private void Configure(DbContextOptionsBuilder options) => options.UseMySql(ConnectionString, ServerVersion) .ConfigureWarnings(warnings => warnings.Ignore(RelationalEventId.PendingModelChangesWarning)); public JobTrackerContext Context(string owner = "rd21-owner", IInterceptor? interceptor = null) { var options = new DbContextOptionsBuilder(); Configure(options); if (interceptor is not null) options.AddInterceptors(interceptor); return new(options.Options, Mock.Of(user => user.UserId == owner)); } public async ValueTask DisposeAsync() { if (App is not null) await App.DisposeAsync(); if (_created) { await using var connection = new MySqlConnection(_adminConnection); await connection.OpenAsync(); await using var command = connection.CreateCommand(); command.CommandText = $"DROP DATABASE `{Name}`;"; await command.ExecuteNonQueryAsync(); _created = false; } var temp = Path.GetFullPath(Path.GetTempPath()).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; if (!_root.StartsWith(temp, StringComparison.OrdinalIgnoreCase) || Path.GetFileName(_root) != Name) throw new InvalidOperationException("Refusing cleanup outside this fixture's temporary directory."); if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); } }