using System; using System.IO; using Xunit; namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard; /// /// Regression guard: WizardDraftRepository must not use the (CommandDefinition) /// overload of Dapper. Dapper.AOT 1.0.48 only generates interceptors for the /// (sql, object?) extension overloads; using CommandDefinition falls back to /// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and /// throws PlatformNotSupportedException on NativeAOT (v3.9.0 wizard regression). /// The v3.9.1 hotfix switched all four methods to the direct overload. /// public sealed class WizardDraftRepositoryAotShapeTests { [Fact] public void WizardDraftRepository_GetActiveAsync_ShouldNotUseCommandDefinition() { var repoRoot = FindRepositoryRoot(); var source = File.ReadAllText(Path.Combine( repoRoot, "src", "GmRelay.Shared", "Features", "Sessions", "CreateSession", "Wizard", "WizardDraftRepository.cs")); var getActive = ExtractMethodBody(source, "GetActiveAsync", ""); Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal); } [Theory] [InlineData("UpsertAsync")] [InlineData("DeleteAsync")] [InlineData("DeleteExpiredAsync")] public void WizardDraftRepository_MutatingMethods_ShouldNotUseCommandDefinition(string methodName) { var repoRoot = FindRepositoryRoot(); var source = File.ReadAllText(Path.Combine( repoRoot, "src", "GmRelay.Shared", "Features", "Sessions", "CreateSession", "Wizard", "WizardDraftRepository.cs")); var body = ExtractMethodBody(source, methodName, ""); Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal); } /// /// WizardDraftRepository was the only AOT-fatal site in v3.9.0, but the /// same pattern (CommandDefinition on a Dapper extension that the AOT /// generator cannot reach) is repeated in 4 club-picker / permission /// lookups across Telegram and Discord messengers. v3.9.2 hotfix /// converted them all to the direct (sql, params) overload. Lock the /// regression so the next refactor doesn't reintroduce it. /// [Theory] [InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs", "GetOwnerClubsAsync", "")] [InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs", "GetOwnerClubsAsync", "")] [InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs", "LoadClubsAsync", "internal static class WizardClubLookup")] [InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs", "LoadManagerUserIdsAsync", "")] public void ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition(string relativePath, string methodName, string containingClass) { var repoRoot = FindRepositoryRoot(); var source = File.ReadAllText(Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); var body = ExtractMethodBody(source, methodName, containingClass); Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal); } /// /// AOT RowFactory for WizardDraft expects to read timestamps as /// (UTC). If a future refactor switches the /// properties back to , the AOT row factory /// will throw InvalidCastException: DateTime → DateTimeOffset /// on the first query against wizard_drafts. /// [Fact] public void WizardDraft_TimestampsMustBeDateTime_NotDateTimeOffset() { var repoRoot = FindRepositoryRoot(); var source = File.ReadAllText(Path.Combine( repoRoot, "src", "GmRelay.Shared", "Features", "Sessions", "CreateSession", "Wizard", "WizardDraft.cs")); Assert.Contains("public DateTime CreatedAt", source, StringComparison.Ordinal); Assert.Contains("public DateTime UpdatedAt", source, StringComparison.Ordinal); Assert.Contains("public DateTime ExpiresAt", source, StringComparison.Ordinal); Assert.DoesNotContain("public DateTimeOffset CreatedAt", source, StringComparison.Ordinal); Assert.DoesNotContain("public DateTimeOffset UpdatedAt", source, StringComparison.Ordinal); Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal); } private static string ExtractMethodBody(string source, string methodName, string containingClass) { var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal); // If a containing class is given (non-empty), narrow the search // to the first occurrence AFTER the class declaration. This is // needed when the same method name is used as a call site // elsewhere in the file. if (!string.IsNullOrEmpty(containingClass)) { var classIdx = source.IndexOf(containingClass, StringComparison.Ordinal); if (classIdx < 0) { throw new InvalidOperationException($"Could not locate class {containingClass} in source."); } searchFrom = source.IndexOf(methodName, classIdx, StringComparison.Ordinal); } if (searchFrom < 0) { throw new InvalidOperationException($"Could not locate {methodName} in source."); } // Accept any return type: `public async Task` (no result) or // `public async Task` (with result). Search for the keyword // "Task" in a 60-char window before the method name so we also // pick up `public static async Task>`. var windowStart = Math.Max(0, searchFrom - 60); var idx = source.IndexOf("Task", windowStart, StringComparison.Ordinal); if (idx < 0 || idx >= searchFrom) { throw new InvalidOperationException($"Could not locate {methodName} declaration in source."); } var braceStart = source.IndexOf('{', idx); if (braceStart < 0) { throw new InvalidOperationException($"Could not locate body opening brace for {methodName}."); } var depth = 1; var pos = braceStart + 1; while (pos < source.Length && depth > 0) { switch (source[pos]) { case '{': depth++; break; case '}': depth--; break; } pos++; } return source.Substring(braceStart, pos - braceStart); } private static string FindRepositoryRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null) { if (File.Exists(Path.Combine(directory.FullName, "Directory.Build.props"))) { return directory.FullName; } directory = directory.Parent; } throw new InvalidOperationException("Could not locate repository root."); } }