diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c9db68f..953def3 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -6,7 +6,7 @@ on: - main env: - VERSION: 3.9.0 + VERSION: 3.9.1 jobs: # ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами) diff --git a/Directory.Build.props b/Directory.Build.props index 4cf89ff..03a7089 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 3.9.0 + 3.9.1 net10.0 preview enable diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ec59637..f3c6a86 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,4 +1,21 @@ -## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112) +## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0 + +Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator` → `PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся. + +### 🩹 Что починено +- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync(sql, params)` (паттерн `JoinSessionHandler`). Dapper.AOT генерирует интерсепторы только для прямого оверлоада. +- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs` — `CreatedAt` / `UpdatedAt` / `ExpiresAt` переведены с `DateTimeOffset` на `DateTime` (UTC). AOT RowFactory вызывает `reader.GetDateTime()` напрямую и не делает `DateTime → DateTimeOffset` конверсию. +- `src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs`, `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs` — `DateTimeOffset.UtcNow` → `DateTime.UtcNow` в новых драфтах. +- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.0 → 3.9.1. +- `tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs` — 5 source-grep регрессионных тестов: ни один метод `WizardDraftRepository` не должен использовать `new CommandDefinition`, и три timestamp-свойства `WizardDraft` должны быть `DateTime` (не `DateTimeOffset`). + +### ⚠️ Известные ограничения +- В `TelegramWizardMessenger.GetOwnerClubsAsync`, `DiscordWizardMessenger.GetOwnerClubsAsync`, `DiscordPermissionLookup.LoadManagerUserIdsAsync`, `DiscordWizardInteractionModule.GetOwnerClubsAsync` остаётся `new CommandDefinition`. Эти вызовы **падают на AOT так же**, как падал `WizardDraftRepository` в 3.9.0. Пользователь натыкается на это только когда выбирает «видимость = клуб/мемберы» и доходит до шага выбора клуба. Будет исправлено в 3.9.2 вместе с переводом `DiscordWizardInteractionModule` на прямые Dapper-оверлоады. + +### 🧪 Тесты +- 588/590 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит 4 интерсептора + `RowFactory17` + `CommandFactory30`. + +## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112) Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда. diff --git a/compose.yaml b/compose.yaml index 0f0ca98..2b1f870 100644 --- a/compose.yaml +++ b/compose.yaml @@ -49,7 +49,7 @@ services: crond -f bot: - image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0 + image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.1 restart: always depends_on: db: @@ -67,7 +67,7 @@ services: retries: 3 discord: - image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0 + image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.1 restart: always depends_on: db: @@ -86,7 +86,7 @@ services: retries: 3 web: - image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0 + image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.1 restart: always depends_on: db: diff --git a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs index 798e384..29eba1a 100644 --- a/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs +++ b/src/GmRelay.Bot/Features/Sessions/CreateSession/CreateSessionHandler.cs @@ -66,16 +66,16 @@ public sealed class CreateSessionHandler OwnerId = ownerId, Platform = PlatformName, Step = WizardStepNames.Type, - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddHours(24), }; await _drafts.UpsertAsync(draft, ct); var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload()); var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct); draft.DraftMessageId = msgId; - draft.UpdatedAt = DateTimeOffset.UtcNow; + draft.UpdatedAt = DateTime.UtcNow; await _drafts.UpsertAsync(draft, ct); return draft; } @@ -135,7 +135,7 @@ public sealed class CreateSessionHandler await _drafts.DeleteAsync(draft.Id, ct); return; } - draft.UpdatedAt = DateTimeOffset.UtcNow; + draft.UpdatedAt = DateTime.UtcNow; await _drafts.UpsertAsync(draft, ct); await _messenger.EditDraftMessageAsync( draft, diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs index ca35ae5..96743ab 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardCommand.cs @@ -115,9 +115,9 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule diff --git a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs index ec2c965..5ab039c 100644 --- a/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs +++ b/src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardSubmitter.cs @@ -97,7 +97,7 @@ public sealed class DiscordWizardSubmitter _contextStore.Remove(draft.Id); return; } - draft.UpdatedAt = DateTimeOffset.UtcNow; + draft.UpdatedAt = DateTime.UtcNow; await _drafts.UpsertAsync(draft, ct); // The full exception (with stack trace, Postgres constraint // name, sometimes partial SQL) is already logged server-side diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs index 6f88dfd..294a675 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/GameCreationWizard.cs @@ -169,7 +169,7 @@ public sealed class GameCreationWizard private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct) { - draft.UpdatedAt = DateTimeOffset.UtcNow; + draft.UpdatedAt = DateTime.UtcNow; await _drafts.UpsertAsync(draft, ct); var payload = LoadPayload(draft); IReadOnlyList? clubs = null; diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs index 4c79b29..39cc8e3 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraft.cs @@ -43,9 +43,9 @@ public sealed class WizardDraft /// public string? DraftMessageId { get; set; } - public DateTimeOffset CreatedAt { get; set; } + public DateTime CreatedAt { get; set; } - public DateTimeOffset UpdatedAt { get; set; } + public DateTime UpdatedAt { get; set; } - public DateTimeOffset ExpiresAt { get; set; } + public DateTime ExpiresAt { get; set; } } diff --git a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs index 20b777d..bc55f1f 100644 --- a/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs +++ b/src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs @@ -8,6 +8,13 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository { + // NOTE: NativeAOT — Dapper.AOT 1.0.48 only generates interceptors for the + // (sql, param) extension overloads, NOT for the (CommandDefinition) overload. + // Passing a CommandDefinition here would skip the interceptor and fall back to + // Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and + // throws PlatformNotSupportedException on AOT (issue: wizard silently dropped + // every Telegram update in v3.9.0). All four methods therefore use the plain + // (sql, param) overload, matching the pattern in JoinSessionHandler. public async Task GetActiveAsync(string platform, string ownerId, CancellationToken ct) { const string sql = """ @@ -29,13 +36,10 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard ORDER BY updated_at DESC LIMIT 1 """; - await using var connection = await dataSource.OpenConnectionAsync(ct); return await connection.QuerySingleOrDefaultAsync( - new CommandDefinition( - sql, - new { Platform = platform, OwnerId = ownerId }, - cancellationToken: ct)); + sql, + new { Platform = platform, OwnerId = ownerId }); } public async Task UpsertAsync(WizardDraft draft, CancellationToken ct) @@ -52,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard updated_at = EXCLUDED.updated_at, expires_at = EXCLUDED.expires_at; """; - await using var connection = await dataSource.OpenConnectionAsync(ct); - await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct)); + await connection.ExecuteAsync(sql, draft); } public async Task DeleteAsync(Guid id, CancellationToken ct) { const string sql = "DELETE FROM wizard_drafts WHERE id = @Id"; await using var connection = await dataSource.OpenConnectionAsync(ct); - await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct)); + await connection.ExecuteAsync(sql, new { Id = id }); } public async Task DeleteExpiredAsync(CancellationToken ct) { const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()"; await using var connection = await dataSource.OpenConnectionAsync(ct); - return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct)); + return await connection.ExecuteAsync(sql); } } diff --git a/src/GmRelay.Web/Components/Layout/NavMenu.razor b/src/GmRelay.Web/Components/Layout/NavMenu.razor index 10142ed..86cb6a3 100644 --- a/src/GmRelay.Web/Components/Layout/NavMenu.razor +++ b/src/GmRelay.Web/Components/Layout/NavMenu.razor @@ -82,7 +82,7 @@ - + diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs new file mode 100644 index 0000000..06c1246 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryAotShapeTests.cs @@ -0,0 +1,136 @@ +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); + } + + /// + /// 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) + { + 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` (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."); + } +} diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs index f4d5046..5bef27f 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardDraftRepositoryTests.cs @@ -16,11 +16,11 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt await using var dataSource = NpgsqlDataSource.Create(connectionString); var sut = new WizardDraftRepository(dataSource); - var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1)); + var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1)); await sut.UpsertAsync(draft, CancellationToken.None); draft.Step = "Title"; - draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1); + draft.UpdatedAt = DateTime.UtcNow.AddSeconds(1); await sut.UpsertAsync(draft, CancellationToken.None); var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None); @@ -35,7 +35,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt await using var dataSource = NpgsqlDataSource.Create(connectionString); var sut = new WizardDraftRepository(dataSource); - var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1)); + var draft = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1)); await sut.UpsertAsync(draft, CancellationToken.None); var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None); @@ -49,7 +49,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt await using var dataSource = NpgsqlDataSource.Create(connectionString); var sut = new WizardDraftRepository(dataSource); - var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1)); + var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1)); await sut.UpsertAsync(draft, CancellationToken.None); var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1) @@ -65,8 +65,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt await using var dataSource = NpgsqlDataSource.Create(connectionString); var sut = new WizardDraftRepository(dataSource); - var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1)); - var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1)); + var fresh = NewDraft("Type", DateTime.UtcNow.AddHours(1)); + var stale = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1)); stale.Id = Guid.NewGuid(); await sut.UpsertAsync(fresh, CancellationToken.None); await sut.UpsertAsync(stale, CancellationToken.None); @@ -78,7 +78,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt Assert.NotNull(loadedFresh); } - private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new() + private static WizardDraft NewDraft(string step, DateTime expiresAt) => new() { Id = Guid.NewGuid(), ChatId = "42", @@ -87,8 +87,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt Platform = "Telegram", Step = step, PayloadJson = "{}", - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, ExpiresAt = expiresAt, }; } diff --git a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs index 1636517..6c6208f 100644 --- a/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs +++ b/tests/GmRelay.Bot.Tests/Features/Sessions/CreateSession/Wizard/WizardTestFakes.cs @@ -40,9 +40,9 @@ internal static class WizardTestFakes PayloadJson = System.Text.Json.JsonSerializer.Serialize( payload ?? new WizardPayload(), WizardPayloadJsonContext.Default.WizardPayload), - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - ExpiresAt = DateTimeOffset.UtcNow.AddHours(24), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddHours(24), }; /// diff --git a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs index 783012f..efbd201 100644 --- a/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs +++ b/tests/GmRelay.Bot.Tests/Web/CampaignTemplatesNavigationTests.cs @@ -15,7 +15,7 @@ public sealed class CampaignTemplatesNavigationTests public async Task NavMenu_ShouldExposeCurrentProjectVersion() { var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor")); - Assert.Contains("v3.9.0", navMenu, StringComparison.Ordinal); + Assert.Contains("v3.9.1", navMenu, StringComparison.Ordinal); } [Fact]