f796b7d1e4
Production regression in 3.9.0: Telegram bot silently dropped every update. WizardDraftRepository.GetActiveAsync was called on every Telegram update (via UpdateRouter -> TryGetWizardContext) and threw System.PlatformNotSupportedException in NativeAOT, because Dapper.AOT 1.0.48 only generates interceptors for the (sql, object?) extension overloads and NOT for the (CommandDefinition) overload. The runtime then fell back to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and fails on AOT. TelegramBotService swallowed the exception, so /newsession appeared to start but no button press reached the wizard and no session was created. Two related changes in WizardDraft: 1. Switched WizardDraftRepository.* from 'new CommandDefinition(sql, params, cancellationToken: ct)' to the direct 'connection.Query*(sql, params)' overload, matching the working pattern in JoinSessionHandler. Dapper.AOT now generates CommandFactory30<WizardDraft> + RowFactory17<WizardDraft> + QuerySingleOrDefaultAsync37<WizardDraft> for all four methods. 2. WizardDraft.CreatedAt/UpdatedAt/ExpiresAt are now DateTime (UTC) instead of DateTimeOffset. AOT RowFactory calls reader.GetDateTime() directly and does not perform DateTime -> DateTimeOffset conversion; the previous type raised InvalidCastException on the very first wizard_drafts query. All 588/590 tests pass (2 pre-existing skipped, +5 new AOT regression tests in WizardDraftRepositoryAotShapeTests). dotnet format clean. Bumps: 3.9.0 -> 3.9.1. Note: GetOwnerClubsAsync (Telegram/Discord), DiscordPermissionLookup, and DiscordWizardInteractionModule.GetOwnerClubsAsync still use CommandDefinition and will hit the same Reflection.Emit AOT failure when the user reaches the PickClub visibility step. Follow-up in 3.9.2.
137 lines
5.0 KiB
C#
137 lines
5.0 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>
|
|
/// 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)
|
|
{
|
|
var searchFrom = source.IndexOf(methodName, 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" right before the method name.
|
|
var idx = source.IndexOf("Task", searchFrom - 16, StringComparison.Ordinal);
|
|
if (idx < 0 || !source.Substring(idx, 4).StartsWith("Task", StringComparison.Ordinal))
|
|
{
|
|
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.");
|
|
}
|
|
}
|