Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 | |||
| 99a58d7835 | |||
| f491727cec | |||
| 2c9016a383 | |||
| 065e8011ee |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.9.1
|
||||
VERSION: 3.9.3
|
||||
|
||||
jobs:
|
||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||
@@ -72,7 +72,27 @@ jobs:
|
||||
steps:
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
# upstream install.sh. Rationale:
|
||||
# 1. install.sh resolves the positional tag against the
|
||||
# GitHub releases API; when a release is unpublished or
|
||||
# yanked, the script fails with
|
||||
# `unable to find '<tag>' - use 'latest' or see ...`
|
||||
# even when the release once existed. We hit this with
|
||||
# v0.71.0.
|
||||
# 2. Docker Hub tags are content-addressed and rarely
|
||||
# removed, so a pinned image tag is much more stable.
|
||||
# 3. The image is multi-arch (linux/amd64, linux/arm64,
|
||||
# linux/ppc64le, linux/s390x) so the same tag works on
|
||||
# the GitHub-hosted runner and on the ARM64 Pi runner.
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.70.0"
|
||||
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||
docker rm trivy-tmp >/dev/null
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Scan Bot image
|
||||
run: |
|
||||
|
||||
@@ -47,7 +47,19 @@ jobs:
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
# upstream install.sh. Rationale (see deploy.yml for the long
|
||||
# version): the GitHub release tag we pinned (v0.71.0) was
|
||||
# unpublished, and install.sh fails hard on missing tags.
|
||||
# Docker Hub images are content-addressed and rarely removed,
|
||||
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
|
||||
set -euo pipefail
|
||||
TRIVY_VERSION="0.70.0"
|
||||
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
|
||||
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
|
||||
docker rm trivy-tmp >/dev/null
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Trivy filesystem security scan
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>3.9.1</Version>
|
||||
<Version>3.9.3</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
+21
-1
@@ -1,4 +1,24 @@
|
||||
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
|
||||
## 🐞 Patch 3.9.2 — Hotfix: club-picker молча падал на шаге «Видимость» (3.9.1 неполный)
|
||||
|
||||
В 3.9.1 был починен только `WizardDraftRepository` (самый частый путь). Тот же баг с `(CommandDefinition)`-оверлоадом Dapper остался в 4 клуб-пикерах / permission-локапах — Wizard доходил до шага «Видимость», и при выборе «Публичная в витрине клуба» / «Только для членов клуба» `PersistAndRenderAsync` дёргал `_messenger.GetOwnerClubsAsync` → `PlatformNotSupportedException` → `GameCreationWizard` глотал исключение → кнопка `ack` отправлялась с тостом «⚠️ Ошибка», но нового шага пользователь не видел. Privacy «не цеплялась».
|
||||
|
||||
### 🩹 Что починено
|
||||
- `src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs::GetOwnerClubsAsync` — `new CommandDefinition(...)` → прямой `QueryAsync<WizardClubOption>(sql, params)`.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs::GetOwnerClubsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs::WizardClubLookup.LoadClubsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs::LoadManagerUserIdsAsync` — то же.
|
||||
- `src/GmRelay.DiscordBot/GmRelay.DiscordBot.csproj` — добавлен `<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>` (раньше был только в Shared и Bot). Без этого `Dapper.AOT`-генератор не сканировал DiscordBot, и `new CommandDefinition`-вызовы в DiscordBot падали бы в рантайме даже после фикса сигнатур.
|
||||
- `src/GmRelay.DiscordBot/Program.cs` — добавлен `[module: Dapper.DapperAot]` (раньше только в Bot и Shared).
|
||||
- `Directory.Build.props` / `compose.yaml` / `.gitea/workflows/deploy.yml` / `NavMenu.razor` — бамп 3.9.1 → 3.9.2.
|
||||
- `tests/.../WizardDraftRepositoryAotShapeTests.cs` — расширены `ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition` на 4 inline-cases + опциональный `containingClass` для дизамбигуации одинаковых имён методов в DiscordWizardInteractionModule.
|
||||
|
||||
### ⚠️ Известные ограничения
|
||||
- Web-проект не под NativeAOT (Blazor Server), там `Dapper.AOT` не подключён и используется обычный Dapper; регрессия его не касается.
|
||||
|
||||
### 🧪 Тесты
|
||||
- 592/594 passed (2 pre-existing skipped), `dotnet format` clean, `dotnet build` 0 warnings/errors, AOT-генератор эмитит интерсепторы для всех 4 клуб-пикеров + `WizardDraftRepository` (всего 5 файлов: 4 в Bot/DiscordBot/DiscordBot + 1 в Shared).
|
||||
|
||||
## 🐞 Patch 3.9.1 — Hotfix: Telegram-визард мёртв после 3.9.0
|
||||
|
||||
Регрессия в `WizardDraftRepository` (NativeAOT). В Telegram **не реагировали кнопки** и **не создавались игры**, потому что Dapper.AOT 1.0.48 не генерирует интерсепторы для оверлоада `(CommandDefinition)` — рантайм падал в `CreateParamInfoGenerator` → `PlatformNotSupportedException` на каждом апдейте, `TelegramBotService` глотал исключение и апдейт терялся.
|
||||
|
||||
|
||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
||||
crond -f
|
||||
|
||||
bot:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.3
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -67,7 +67,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
discord:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.3
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
@@ -86,7 +86,7 @@ services:
|
||||
retries: 3
|
||||
|
||||
web:
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.1
|
||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.3
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -170,7 +170,7 @@ public sealed class CreateSessionHandler
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers ?? 0,
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
@@ -178,11 +178,11 @@ public sealed class CreateSessionHandler
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
private static CreateSessionCommand BuildCommand(
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int maxPlayers,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
|
||||
@@ -82,6 +82,14 @@ public sealed class TelegramWizardMessenger(
|
||||
// and game_groups has no `club_id` FK). The picker therefore returns the
|
||||
// game_groups the owner manages as a GM (via group_managers), matching
|
||||
// the WizardClubOption contract (UUID id, name) used downstream.
|
||||
//
|
||||
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
|
||||
// (sql, object?) extension overload — not the (CommandDefinition) overload.
|
||||
// The wizard reaches this method on the PickClub visibility step
|
||||
// (issue #112 follow-up); using CommandDefinition here would fall back
|
||||
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
|
||||
// and throws PlatformNotSupportedException on AOT. Same root cause as
|
||||
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
|
||||
const string sql = """
|
||||
SELECT g.id AS ClubId,
|
||||
g.name AS Name
|
||||
@@ -95,10 +103,8 @@ public sealed class TelegramWizardMessenger(
|
||||
""";
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
var rows = await connection.QueryAsync<WizardClubOption>(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new { Platform = "Telegram", ExternalId = ownerId },
|
||||
cancellationToken: ct));
|
||||
sql,
|
||||
new { Platform = "Telegram", ExternalId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,10 @@ internal static class DiscordPermissionLookup
|
||||
""";
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||
var rows = await connection.QueryAsync<ulong>(
|
||||
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
|
||||
sql,
|
||||
new { GuildId = guildId.ToString() });
|
||||
return rows.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,11 +537,10 @@ internal static class WizardClubLookup
|
||||
ORDER BY g.name
|
||||
""";
|
||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new { Platform = "Discord", OwnerId = ownerId },
|
||||
cancellationToken: ct));
|
||||
sql,
|
||||
new { Platform = "Discord", OwnerId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ public sealed class DiscordWizardMessenger : IWizardMessenger
|
||||
ORDER BY g.name
|
||||
""";
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||
// NativeAOT: direct (sql, params) overload — see
|
||||
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
|
||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||
new CommandDefinition(
|
||||
sql,
|
||||
new { Platform = "Discord", ExternalId = ownerId },
|
||||
cancellationToken: ct));
|
||||
sql,
|
||||
new { Platform = "Discord", ExternalId = ownerId });
|
||||
return rows.AsList();
|
||||
}
|
||||
|
||||
|
||||
@@ -252,11 +252,12 @@ public static class DiscordWizardStep
|
||||
|
||||
private static DiscordWizardRender RenderCapacity() => new(
|
||||
"👥 Лимит мест",
|
||||
"Введите лимит (1..50) и выберите waitlist.",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
@@ -371,11 +372,12 @@ public static class DiscordWizardStep
|
||||
|
||||
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
||||
"👥 Лимит слотов",
|
||||
"Введите лимит (1..50) и выберите waitlist.",
|
||||
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||
new[]
|
||||
{
|
||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
||||
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
|
||||
Row(ControlBtn("⬅️ Назад", "back"),
|
||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||
},
|
||||
@@ -417,6 +419,10 @@ public static class DiscordWizardStep
|
||||
{
|
||||
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
else if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
sb.Append("👥 Без лимита, waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
||||
}
|
||||
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ public sealed class DiscordWizardSubmitter
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers ?? 0,
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
@@ -142,11 +142,11 @@ public sealed class DiscordWizardSubmitter
|
||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||
|
||||
private static CreateSessionCommand BuildCommand(
|
||||
internal static CreateSessionCommand BuildCommand(
|
||||
WizardDraft draft,
|
||||
WizardPayload p,
|
||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||
int maxPlayers,
|
||||
int? maxPlayers,
|
||||
bool isOneShot)
|
||||
{
|
||||
var user = new PlatformUser(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -27,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
|
||||
using NetCord.Services.ComponentInteractions;
|
||||
using Npgsql;
|
||||
|
||||
[module: Dapper.DapperAot]
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||
@@ -302,6 +302,7 @@ public sealed class GameCreationWizard
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||
"no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
|
||||
@@ -416,7 +417,7 @@ public sealed class GameCreationWizard
|
||||
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
||||
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
||||
private static string? SetMaxPlayers(WizardPayload p, int v)
|
||||
private static string? SetMaxPlayers(WizardPayload p, int? v)
|
||||
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
|
||||
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
|
||||
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
||||
|
||||
@@ -97,11 +97,12 @@ public static class WizardStepViewBuilder
|
||||
BackCancel());
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
|
||||
new List<WizardAction>
|
||||
{
|
||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
||||
});
|
||||
|
||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="nav-version">v3.9.1</div>
|
||||
<div class="nav-version">v3.9.3</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using NetCord.Rest;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
|
||||
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
|
||||
/// a session with no player cap (null in <c>sessions.max_players</c>), the
|
||||
/// same affordance the Telegram wizard provides.
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardStepCapacityRenderTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var labels = ExtractButtonLabels(render);
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderCapacity_NoLimitButton_HasChoiceCustomIdForNoLimit()
|
||||
{
|
||||
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
|
||||
var render = DiscordWizardStep.Render(draft, new WizardPayload());
|
||||
|
||||
var buttons = ExtractButtons(render);
|
||||
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
|
||||
Assert.NotNull(noLimit);
|
||||
Assert.StartsWith("wizard:btn:choice:Capacity:no_limit", noLimit!.CustomId);
|
||||
}
|
||||
|
||||
private static System.Collections.Generic.List<string> ExtractButtonLabels(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.Select(b => b.Label ?? string.Empty)
|
||||
.ToList();
|
||||
|
||||
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
|
||||
DiscordWizardStep.DiscordWizardRender render) =>
|
||||
render.Components
|
||||
.OfType<ActionRowProperties>()
|
||||
.SelectMany(r => r.Components)
|
||||
.OfType<ButtonProperties>()
|
||||
.ToList();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using GmRelay.DiscordBot.Features.Sessions.Wizard;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Discord.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
|
||||
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
|
||||
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
|
||||
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
|
||||
/// <c>0</c>. <c>0</c> would violate the DB CHECK
|
||||
/// <c>ck_sessions_max_players</c> in V006 and the contract that
|
||||
/// <c>null</c> means "no limit".
|
||||
/// </summary>
|
||||
public sealed class DiscordWizardSubmitterBuildCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Null(cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = DiscordWizardSubmitter.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using Xunit;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
|
||||
/// when the wizard payload carries no player limit (e.g. user picked
|
||||
/// «♾ Без лимита» on the Capacity step), the resulting
|
||||
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
|
||||
/// <c>0</c> would violate the DB CHECK constraint
|
||||
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
|
||||
/// <c>NULL</c>, which is the wire-level representation of "no limit".
|
||||
/// </summary>
|
||||
public sealed class CreateSessionHandlerBuildCommandTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Null(cmd.MaxPlayers);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||
{
|
||||
var draft = new WizardDraft
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ChatId = "42",
|
||||
OwnerId = "100",
|
||||
Step = "confirm",
|
||||
};
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||
MaxPlayers = 5,
|
||||
},
|
||||
};
|
||||
|
||||
var cmd = CreateSessionHandler.BuildCommand(
|
||||
draft,
|
||||
payload,
|
||||
new[] { payload.Single!.ScheduledAt!.Value },
|
||||
payload.Single.MaxPlayers,
|
||||
isOneShot: true);
|
||||
|
||||
Assert.Equal(5, cmd.MaxPlayers);
|
||||
}
|
||||
}
|
||||
+27
@@ -23,6 +23,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
// Capacity → Visibility
|
||||
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
|
||||
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
||||
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
|
||||
// Visibility → Publish (public, no club)
|
||||
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
||||
// Visibility → PickClub
|
||||
@@ -70,6 +71,32 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
Assert.Equal(240, dur.GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("single", out var single));
|
||||
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
|
||||
// поэтому null-MaxPlayers просто не пишется. Оба варианта
|
||||
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
|
||||
// и уйдут в БД как NULL — то есть «без лимита».
|
||||
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
|
||||
{
|
||||
Assert.True(
|
||||
maxPlayers.ValueKind == JsonValueKind.Null,
|
||||
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
|
||||
{
|
||||
|
||||
+45
-6
@@ -28,7 +28,7 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
||||
"Wizard",
|
||||
"WizardDraftRepository.cs"));
|
||||
|
||||
var getActive = ExtractMethodBody(source, "GetActiveAsync");
|
||||
var getActive = ExtractMethodBody(source, "GetActiveAsync", "");
|
||||
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,29 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
||||
"Wizard",
|
||||
"WizardDraftRepository.cs"));
|
||||
|
||||
var body = ExtractMethodBody(source, methodName);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -82,9 +104,24 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
||||
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string ExtractMethodBody(string source, string methodName)
|
||||
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.");
|
||||
@@ -92,9 +129,11 @@ public sealed class WizardDraftRepositoryAotShapeTests
|
||||
|
||||
// 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))
|
||||
// "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.");
|
||||
}
|
||||
|
||||
+1
@@ -76,6 +76,7 @@ public sealed class WizardStepRenderTests
|
||||
var labels = ButtonLabels(kb);
|
||||
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
|
||||
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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.1", navMenu, StringComparison.Ordinal);
|
||||
Assert.Contains("v3.9.3", navMenu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user