using FluentAssertions; using MySql.Data.MySqlClient; using SlpModularCms.Core.Hosting; using Xunit; namespace SlpModularCms.Core.Tests.Hosting; /// /// Guards the failure classification of the startup migration. /// /// /// Startup migration distinguishes two failures that mean very different things: /// a database that is not up yet (normal when the application and the database server start /// together after a host reboot) versus a migration that is broken. Retrying the first is /// correct; retrying the second only delays the inevitable and fills the log. /// /// MigrateCoreDatabase itself needs a composed WebApplication, so the classifier is /// exercised directly here; the composed startup path is verified at the phase-level Build and /// Test stage by starting both hosts. /// public class DatabaseMigrationExtensionsTests { [Fact] public void MaxConnectionAttempts_ShouldAllowForAHostRebootWindow() { // Enough attempts that a database server starting alongside the application is tolerated, // few enough that a genuinely unreachable database still fails promptly. DatabaseMigrationExtensions.MaxConnectionAttempts.Should().BeGreaterThan(1); DatabaseMigrationExtensions.MaxConnectionAttempts.Should().BeLessThanOrEqualTo(10); } [Theory] [MemberData(nameof(TransientFailures))] public void IsTransientConnectionFailure_ShouldRecogniseConnectionProblems(Exception exception) { InvokeClassifier(exception).Should().BeTrue(); } [Theory] [MemberData(nameof(NonTransientFailures))] public void IsTransientConnectionFailure_ShouldNotRecogniseMigrationProblems(Exception exception) { // A broken migration must fail immediately. Classifying it as transient would hide a // real fault behind a retry loop. InvokeClassifier(exception).Should().BeFalse(); } public static TheoryData TransientFailures() => [ MakeMySqlException(), new TimeoutException("Connect Timeout expired."), new InvalidOperationException("wrapper", MakeMySqlException()), ]; public static TheoryData NonTransientFailures() => [ new InvalidOperationException("There is already an object named 'Users' in the database."), new NotSupportedException("The migration cannot be applied."), new AggregateException(new InvalidOperationException("pending model changes")), ]; private static bool InvokeClassifier(Exception exception) { var method = typeof(DatabaseMigrationExtensions).GetMethod( "IsTransientConnectionFailure", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); method.Should().NotBeNull("the failure classifier is the behaviour under test"); return (bool)method!.Invoke(null, [exception])!; } /// /// has no public constructor, so one is produced through the /// framework's own factory path via reflection. /// private static Exception MakeMySqlException() { try { // Deliberately unreachable host and a very short timeout: this genuinely produces a // MySqlException rather than a hand-built stand-in, so the classifier is tested against // the real type it will encounter in production. using var connection = new MySqlConnection( "Server=localhost;Port=9;Database=none;Uid=none;Pwd=none;Connection Timeout=1"); connection.Open(); } catch (Exception ex) { return ex; } throw new InvalidOperationException("Expected the connection attempt to fail."); } }