2c9016a383
The 3.9.1 hotfix only repaired WizardDraftRepository, the most common
Dapper call in the wizard. The same AOT-unsafe CommandDefinition pattern
remained in 4 other places that the user hit immediately after the
deploy: the 'Choose visibility' wizard step triggers GetOwnerClubsAsync
when the user picks 'Публичная в витрине клуба' or 'Только для членов
клуба'. The wizard swallowed PlatformNotSupportedException, the
callback ack replied with '⚠️ Ошибка', and the next step never rendered.
Privacy 'didn't stick' from the user's perspective.
Two changes to fix the Discord side as well:
1. Switched GetOwnerClubsAsync / LoadClubsAsync / LoadManagerUserIdsAsync
to the direct (sql, params) overload across TelegramWizardMessenger,
DiscordWizardMessenger, DiscordWizardInteractionModule, and
DiscordPermissionLookup — same pattern as the 3.9.1 fix.
2. Added Dapper.AOT module attribute ([module: Dapper.DapperAot]) and
InterceptorsPreviewNamespaces to the DiscordBot project. The
DiscordBot assembly was previously skipped by the AOT source
generator, so even the direct-overload fix wouldn't have produced
interceptors for the Discord-specific Dapper call sites. With this
addition, the generator emits 3 DiscordBot-specific interceptors
(DiscordWizardMessenger, DiscordWizardInteractionModule,
DiscordPermissionLookup) and the AssemblyLoad ships with the right
GmRelay.DiscordBot.generated.cs.
Also expanded the AOT shape regression tests to cover all 4
CommandDefinition sites + added a 'containingClass' parameter to
ExtractMethodBody to disambiguate the duplicated LoadClubsAsync names
in DiscordWizardInteractionModule.
Bumps: 3.9.1 -> 3.9.2.
176 lines
7.2 KiB
C#
176 lines
7.2 KiB
C#
using System;
|
|
using System.IO;
|
|
using Xunit;
|
|
|
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// AOT RowFactory for WizardDraft expects to read timestamps as
|
|
/// <see cref="DateTime"/> (UTC). If a future refactor switches the
|
|
/// properties back to <see cref="DateTimeOffset"/>, the AOT row factory
|
|
/// will throw <c>InvalidCastException: DateTime → DateTimeOffset</c>
|
|
/// on the first query against wizard_drafts.
|
|
/// </summary>
|
|
[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<int>` (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<IReadOnlyList<ulong>>`.
|
|
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.");
|
|
}
|
|
}
|