Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bbd58142db | |||
| 956ec01583 | |||
| 5014ca5c58 | |||
| efd86bca0a | |||
| 2241568bac | |||
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad | |||
| 1f3fb6e89e | |||
| e3e6e841b8 | |||
| a0a84965b3 | |||
| 67e8d5b558 | |||
| 593f8a62fb | |||
| aee0ac1e6c | |||
| 68945d931f | |||
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 | |||
| 99a58d7835 | |||
| f491727cec | |||
| 2c9016a383 | |||
| 065e8011ee |
@@ -6,7 +6,7 @@ on:
|
||||
- main
|
||||
|
||||
env:
|
||||
VERSION: 3.9.1
|
||||
VERSION: 3.9.9
|
||||
|
||||
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.9</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.9
|
||||
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.9
|
||||
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.9
|
||||
restart: always
|
||||
depends_on:
|
||||
db:
|
||||
|
||||
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
|
||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем wget для healthcheck
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
||||
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libgssapi-krb5-2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Копируем только AOT-результаты из билда
|
||||
|
||||
@@ -5,11 +5,13 @@ using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using Telegram.Bot.Types;
|
||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||
|
||||
@@ -31,17 +33,23 @@ public sealed class CreateSessionHandler
|
||||
private readonly SharedCreateSessionHandler _shared;
|
||||
private readonly IWizardMessenger _messenger;
|
||||
private readonly ILogger<CreateSessionHandler> _log;
|
||||
private readonly IPlatformMessenger? _platformMessenger;
|
||||
private readonly NpgsqlDataSource? _dataSource;
|
||||
|
||||
public CreateSessionHandler(
|
||||
IWizardDraftRepository drafts,
|
||||
SharedCreateSessionHandler shared,
|
||||
IWizardMessenger messenger,
|
||||
ILogger<CreateSessionHandler> log)
|
||||
ILogger<CreateSessionHandler> log,
|
||||
IPlatformMessenger? platformMessenger = null,
|
||||
NpgsqlDataSource? dataSource = null)
|
||||
{
|
||||
_drafts = drafts;
|
||||
_shared = shared;
|
||||
_messenger = messenger;
|
||||
_log = log;
|
||||
_platformMessenger = platformMessenger;
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,19 +114,24 @@ public sealed class CreateSessionHandler
|
||||
}
|
||||
|
||||
var commands = BuildCommands(draft, payload);
|
||||
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||
try
|
||||
{
|
||||
foreach (var cmd in commands)
|
||||
{
|
||||
await _shared.HandleAsync(cmd, ct);
|
||||
var result = await _shared.HandleAsync(cmd, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
result.ErrorMessage ?? "❌ Не удалось создать сессию.",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
created.Add((cmd, result));
|
||||
}
|
||||
var totalSessions = commands.Sum(c => c.ScheduledTimes.Count);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -142,9 +155,89 @@ public sealed class CreateSessionHandler
|
||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||
RetryCancelActions(),
|
||||
ct);
|
||||
return;
|
||||
}
|
||||
|
||||
var totalSessions = created.Sum(c => c.Command.ScheduledTimes.Count);
|
||||
try
|
||||
{
|
||||
foreach (var item in created)
|
||||
{
|
||||
await PublishCreatedSessionAsync(item.Command, item.Result, ct);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "SubmitDraftAsync created draft {DraftId} but failed to publish schedule", draft.Id);
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}, но не удалось опубликовать сообщение для записи: {ex.Message}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
await _messenger.EditDraftMessageAsync(
|
||||
draft,
|
||||
$"✅ Создано: {totalSessions} {(totalSessions == 1 ? "сессия" : "сессий")}",
|
||||
Array.Empty<WizardAction>(),
|
||||
ct);
|
||||
await _drafts.DeleteAsync(draft.Id, ct);
|
||||
}
|
||||
|
||||
private async Task PublishCreatedSessionAsync(CreateSessionCommand command, CreateSessionResult result, CancellationToken ct)
|
||||
{
|
||||
if (_platformMessenger is null || _dataSource is null)
|
||||
{
|
||||
throw new InvalidOperationException("Session publication dependencies are not configured.");
|
||||
}
|
||||
|
||||
if (result.View is null || result.BatchId is null)
|
||||
{
|
||||
throw new InvalidOperationException("Created session result does not contain publication data.");
|
||||
}
|
||||
|
||||
var group = command.Group;
|
||||
var topicCreatedByBot = false;
|
||||
if (string.IsNullOrWhiteSpace(group.ExternalThreadId))
|
||||
{
|
||||
var thread = await _platformMessenger.CreateThreadAsync(group, command.Title, ct);
|
||||
group = group with { ExternalThreadId = thread.ExternalThreadId };
|
||||
topicCreatedByBot = true;
|
||||
}
|
||||
|
||||
var scheduleMessage = await _platformMessenger.SendScheduleAsync(
|
||||
new PlatformScheduleMessage(group, result.View, ExistingMessage: null, command.ImageReference),
|
||||
ct);
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct);
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET thread_id = @ThreadId,
|
||||
batch_message_id = @BatchMessageId,
|
||||
topic_created_by_bot = @TopicCreatedByBot,
|
||||
updated_at = now()
|
||||
WHERE batch_id = @BatchId
|
||||
""",
|
||||
new
|
||||
{
|
||||
result.BatchId,
|
||||
ThreadId = ParseNullableInt(group.ExternalThreadId),
|
||||
BatchMessageId = ParseInt(scheduleMessage.ExternalMessageId),
|
||||
TopicCreatedByBot = topicCreatedByBot
|
||||
});
|
||||
}
|
||||
|
||||
private static int ParseInt(string value) =>
|
||||
int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
|
||||
private static int? ParseNullableInt(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? null
|
||||
: int.Parse(value, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
||||
|
||||
// ── Build shared commands ────────────────────────────────────────
|
||||
// The shared handler creates one session per scheduled time in a
|
||||
// single transaction and assigns the same batch_id to all of them.
|
||||
@@ -170,7 +263,7 @@ public sealed class CreateSessionHandler
|
||||
draft,
|
||||
p,
|
||||
new[] { p.Single?.ScheduledAt ?? default },
|
||||
p.Single?.MaxPlayers ?? 0,
|
||||
p.Single?.MaxPlayers,
|
||||
isOneShot: true),
|
||||
};
|
||||
}
|
||||
@@ -178,11 +271,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(
|
||||
@@ -229,7 +322,8 @@ public sealed class CreateSessionHandler
|
||||
if (p.Type == WizardCreationType.Single)
|
||||
{
|
||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
||||
if (p.Single?.MaxPlayers is null) missingFields.Add("лимит мест");
|
||||
// MaxPlayers = null is a valid "♾ Без лимита" choice
|
||||
// (see GameCreationWizard.ApplyCapacityChoice "no_limit").
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -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")]
|
||||
@@ -82,7 +82,13 @@ public sealed class CreateSessionHandler(
|
||||
AND p.external_user_id = @ExternalGmId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
new
|
||||
{
|
||||
GroupId = groupId,
|
||||
Platform = platform,
|
||||
ExternalGmId = externalUserId,
|
||||
OwnerRole = GroupManagerRoleExtensions.OwnerValue
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -298,12 +298,25 @@ public sealed class GameCreationWizard
|
||||
: (null, "Неверная длительность"),
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
|
||||
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
if (choice is "no_limit")
|
||||
{
|
||||
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
|
||||
}
|
||||
|
||||
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
|
||||
{
|
||||
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||
}
|
||||
|
||||
return choice switch
|
||||
{
|
||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||
_ => (null, "Неизвестный выбор"),
|
||||
};
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
@@ -315,9 +328,15 @@ public sealed class GameCreationWizard
|
||||
};
|
||||
|
||||
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
|
||||
=> Guid.TryParse(choice, out var id)
|
||||
? (NextAfterVisibility(p), SetClubId(p, id))
|
||||
: (null, "Неверный идентификатор клуба");
|
||||
{
|
||||
if (!Guid.TryParse(choice, out var id))
|
||||
{
|
||||
return (null, "Неверный идентификатор клуба");
|
||||
}
|
||||
|
||||
var error = SetClubId(p, id);
|
||||
return (NextAfterVisibility(p), error);
|
||||
}
|
||||
|
||||
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
|
||||
{
|
||||
@@ -416,7 +435,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.9</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
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));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WizardStepNames.Capacity, "wizard:btn:choice:Capacity:no_limit")]
|
||||
[InlineData(WizardStepNames.PoolSlotCapacity, "wizard:btn:choice:PoolSlotCapacity:no_limit")]
|
||||
public void Render_NoLimitButton_HasChoiceCustomIdForNoLimit(string step, string expectedCustomIdPrefix)
|
||||
{
|
||||
var draft = new WizardDraft { Step = step };
|
||||
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(expectedCustomIdPrefix, 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);
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||
|
||||
[CollectionDefinition(Name)]
|
||||
public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture<CreateSessionHandlerPostgresFixture>
|
||||
{
|
||||
public const string Name = "Create session handler PostgreSQL";
|
||||
}
|
||||
|
||||
public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
{
|
||||
return container.StartAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public Task DisposeAsync()
|
||||
{
|
||||
return container.DisposeAsync().AsTask().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
public async Task<string> CreateMigratedDatabaseAsync()
|
||||
{
|
||||
var databaseName = $"create_session_{Guid.NewGuid():N}";
|
||||
|
||||
await using (var adminConnection = new NpgsqlConnection(container.GetConnectionString()))
|
||||
{
|
||||
await adminConnection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
await using var createDatabase = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", adminConnection);
|
||||
await createDatabase.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
var connectionString = new NpgsqlConnectionStringBuilder(container.GetConnectionString())
|
||||
{
|
||||
Database = databaseName,
|
||||
Timeout = 10,
|
||||
CommandTimeout = 30
|
||||
}.ConnectionString;
|
||||
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync().WaitAsync(ContainerTimeout);
|
||||
|
||||
foreach (var migration in GetMigrationPaths())
|
||||
{
|
||||
await using var command = new NpgsqlCommand(await File.ReadAllTextAsync(migration), connection)
|
||||
{
|
||||
CommandTimeout = 30
|
||||
};
|
||||
await command.ExecuteNonQueryAsync().WaitAsync(ContainerTimeout);
|
||||
}
|
||||
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetMigrationPaths()
|
||||
{
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
var migrationsDirectory = Path.Combine(directory.FullName, "src", "GmRelay.Bot", "Migrations");
|
||||
if (Directory.Exists(migrationsDirectory))
|
||||
{
|
||||
return Directory.GetFiles(migrationsDirectory, "V*.sql")
|
||||
.OrderBy(path => Path.GetFileName(path), StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the bot migrations directory.");
|
||||
}
|
||||
}
|
||||
|
||||
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||
public sealed class CreateSessionHandlerIntegrationTests(CreateSessionHandlerPostgresFixture fixture)
|
||||
{
|
||||
[Fact]
|
||||
public async Task HandleAsync_NewPlatformGroup_AddsOwnerAndPersistsSession()
|
||||
{
|
||||
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
var sut = new CreateSessionHandler(dataSource);
|
||||
|
||||
var result = await sut.HandleAsync(
|
||||
new CreateSessionCommand(
|
||||
new PlatformUser(PlatformKind.Telegram, "111111111", "Test GM", "test_gm"),
|
||||
new PlatformGroup(PlatformKind.Telegram, "222222222", "Test Group"),
|
||||
"Test Adventure",
|
||||
string.Empty,
|
||||
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||
null,
|
||||
null,
|
||||
GameSystem.Dnd5e,
|
||||
"Integration regression test",
|
||||
"Online",
|
||||
240,
|
||||
true),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.NotNull(result.BatchId);
|
||||
Assert.NotNull(result.GroupId);
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync();
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT count(*)
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = @group_id
|
||||
AND gm.role = 'Owner'
|
||||
AND p.platform = 'Telegram'
|
||||
AND p.external_user_id = '111111111'
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("group_id", result.GroupId.Value);
|
||||
|
||||
var ownerCount = (long)(await command.ExecuteScalarAsync() ?? 0L);
|
||||
Assert.Equal(1, ownerCount);
|
||||
}
|
||||
}
|
||||
+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);
|
||||
}
|
||||
}
|
||||
+100
-14
@@ -1,19 +1,105 @@
|
||||
using System;
|
||||
using Xunit;
|
||||
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using static GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard.WizardTestFakes;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||
|
||||
/// <summary>
|
||||
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
|
||||
/// on a single-game wizard payload. The success path calls the shared
|
||||
/// <c>CreateSessionHandler.HandleAsync</c>, which needs a real
|
||||
/// <c>NpgsqlDataSource</c> (it runs SQL against game_groups, players,
|
||||
/// sessions, and related tables). The missing-fields and validation
|
||||
/// branches are covered by the dedicated tests in this folder.
|
||||
/// </summary>
|
||||
public sealed class CreateSessionHandlerSubmitSingleDraftTests
|
||||
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||
public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture)
|
||||
{
|
||||
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
|
||||
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
|
||||
throw new NotImplementedException("See Skip reason above.");
|
||||
[Fact]
|
||||
public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs()
|
||||
{
|
||||
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||
var drafts = new FakeWizardDraftRepository();
|
||||
var wizardMessenger = new FakeWizardMessenger();
|
||||
var platformMessenger = new FakePlatformMessenger();
|
||||
|
||||
var sut = new CreateSessionHandler(
|
||||
drafts,
|
||||
new GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler(dataSource),
|
||||
wizardMessenger,
|
||||
NullLogger<CreateSessionHandler>.Instance,
|
||||
platformMessenger,
|
||||
dataSource);
|
||||
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "Тест публикации",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Confirm, payload, ownerId: 111111111);
|
||||
draft.ChatId = "-1003916537960";
|
||||
draft.DraftMessageId = "7";
|
||||
drafts.Seed(draft);
|
||||
|
||||
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||
|
||||
Assert.Single(platformMessenger.CreatedThreads);
|
||||
Assert.Equal("Тест публикации", platformMessenger.CreatedThreads[0].Title);
|
||||
Assert.Single(platformMessenger.SentSchedules);
|
||||
Assert.Equal("456", platformMessenger.SentSchedules[0].Group.ExternalThreadId);
|
||||
Assert.Contains(draft.Id, drafts.DeletedIds);
|
||||
Assert.Contains(wizardMessenger.Edits, edit => edit.Text.Contains("✅ Создано: 1 сессия", StringComparison.Ordinal));
|
||||
|
||||
await using var connection = await dataSource.OpenConnectionAsync();
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT thread_id, batch_message_id, topic_created_by_bot
|
||||
FROM sessions
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
connection);
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
Assert.Equal(456, reader.GetInt32(0));
|
||||
Assert.Equal(789, reader.GetInt32(1));
|
||||
Assert.True(reader.GetBoolean(2));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakePlatformMessenger : IPlatformMessenger
|
||||
{
|
||||
public List<(PlatformGroup Group, string Title)> CreatedThreads { get; } = new();
|
||||
|
||||
public List<PlatformScheduleMessage> SentSchedules { get; } = new();
|
||||
|
||||
public Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct)
|
||||
{
|
||||
CreatedThreads.Add((group, title));
|
||||
return Task.FromResult(new PlatformMessageRef(group.Platform, group.ExternalGroupId, "456", string.Empty));
|
||||
}
|
||||
|
||||
public Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct)
|
||||
{
|
||||
SentSchedules.Add(message);
|
||||
return Task.FromResult(new PlatformMessageRef(
|
||||
message.Group.Platform,
|
||||
message.Group.ExternalGroupId,
|
||||
message.Group.ExternalThreadId,
|
||||
"789"));
|
||||
}
|
||||
|
||||
public Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task SendGroupMessageAsync(PlatformGroup group, string htmlText, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task SendPrivateMessageAsync(PlatformPrivateMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task AnswerInteractionAsync(PlatformInteractionReply reply, CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task SendCalendarFileAsync(PlatformCalendarFile file, CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
+43
@@ -146,4 +146,47 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
||||
Assert.Single(messenger.Edits);
|
||||
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubmitDraftAsync_SingleWithNoLimit_DoesNotReportMaxPlayersAsMissing()
|
||||
{
|
||||
// Regression for #131: pressing "♾ Без лимита" sets MaxPlayers = null.
|
||||
// IsComplete must NOT flag that as a missing field; null means
|
||||
// "no player limit" and is a valid final state.
|
||||
var drafts = new FakeWizardDraftRepository();
|
||||
var messenger = new FakeWizardMessenger();
|
||||
|
||||
var sut = new CreateSessionHandler(
|
||||
drafts,
|
||||
shared: null!,
|
||||
messenger,
|
||||
NullLogger<CreateSessionHandler>.Instance);
|
||||
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Visibility = WizardVisibility.Public,
|
||||
Single = new WizardSingleInput
|
||||
{
|
||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||
MaxPlayers = null,
|
||||
},
|
||||
};
|
||||
var draft = NewDraft(WizardStepNames.Confirm, payload);
|
||||
drafts.Seed(draft);
|
||||
|
||||
await sut.SubmitDraftAsync(draft, CancellationToken.None);
|
||||
|
||||
// Validation must let the no-limit payload through. The shared
|
||||
// handler is null, so anything that reached the database call would
|
||||
// throw a NullReferenceException — that is caught by the retry
|
||||
// path and reported as a "💥 Ошибка:" edit, not a missing-fields
|
||||
// edit. Therefore we assert that NO edit mentions a missing field.
|
||||
Assert.NotEmpty(messenger.Edits);
|
||||
var lastEdit = messenger.Edits[^1].Text;
|
||||
Assert.DoesNotContain("Не заполнены", lastEdit, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-18
@@ -20,9 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
||||
// Duration → DateTime (single, no maxPlayers yet)
|
||||
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
||||
// Capacity → Visibility
|
||||
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
|
||||
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
||||
// Capacity → Visibility (only explicit no-limit can skip numeric capacity)
|
||||
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
|
||||
// Visibility → Publish (public, no club)
|
||||
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
||||
// Visibility → PickClub
|
||||
@@ -71,13 +70,37 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
|
||||
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||
{
|
||||
// The wizard's callback parser uses the step encoded in the callback
|
||||
// (not the draft's current step) to drive transitions. So a stale
|
||||
// "Capacity" button pressed while the user is on System will in fact
|
||||
// move the draft forward as if they had pressed it on Capacity. We
|
||||
// lock that behaviour in.
|
||||
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 StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
|
||||
{
|
||||
// A stale waitlist button from Capacity must not move a draft forward
|
||||
// unless MaxPlayers is already set. Otherwise users can reach Confirm
|
||||
// with a missing capacity and get "Не заполнены поля: лимит мест".
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.System);
|
||||
drafts.Seed(draft);
|
||||
@@ -85,17 +108,13 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PickClub_ValidGuid_ReachesStableStep()
|
||||
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
|
||||
{
|
||||
// The wizard has a quirk: NextAfterVisibility is evaluated before
|
||||
// SetClubId, so a single click leaves the draft still on PickClub.
|
||||
// We assert that the wizard does NOT throw and the messenger is asked
|
||||
// to re-render (i.e. the handler ran end-to-end).
|
||||
var wizard = BuildWizard(out var drafts, out var messenger);
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var clubId = Guid.NewGuid();
|
||||
var payload = new WizardPayload
|
||||
{
|
||||
@@ -111,8 +130,11 @@ public sealed class GameCreationWizardStepTransitionsTests
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
|
||||
Assert.NotEmpty(messenger.Edits);
|
||||
Assert.Equal(WizardStepNames.Publish, draft.Step);
|
||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||
var root = doc.RootElement;
|
||||
Assert.True(root.TryGetProperty("clubId", out var clubIdJson));
|
||||
Assert.Equal(clubId, clubIdJson.GetGuid());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+23
@@ -136,6 +136,29 @@ public sealed class GameCreationWizardValidationTests
|
||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("waitlist:on")]
|
||||
[InlineData("waitlist:off")]
|
||||
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
|
||||
{
|
||||
var wizard = BuildWizard(out var drafts, out _);
|
||||
var draft = NewDraft(WizardStepNames.Capacity,
|
||||
new WizardPayload
|
||||
{
|
||||
Type = WizardCreationType.Single,
|
||||
Title = "T",
|
||||
System = "Dnd5e",
|
||||
DurationMinutes = 240,
|
||||
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
|
||||
});
|
||||
drafts.Seed(draft);
|
||||
|
||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
|
||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||
|
||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("13")]
|
||||
|
||||
+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]
|
||||
|
||||
@@ -14,8 +14,16 @@ public sealed class CampaignTemplatesNavigationTests
|
||||
[Fact]
|
||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
||||
{
|
||||
// Read the version from Directory.Build.props (the canonical source of
|
||||
// truth) so the test doesn't need to be hand-edited on every version
|
||||
// bump. Asserting the rendered NavMenu matches the canonical version
|
||||
// catches real bugs (e.g. someone bumps Directory.Build.props but
|
||||
// forgets to update NavMenu.razor) without false alarms from a stale
|
||||
// hard-coded literal.
|
||||
var propsPath = FindRepositoryFile("Directory.Build.props");
|
||||
var version = ReadVersionFromProps(propsPath);
|
||||
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||
Assert.Contains("v3.9.1", navMenu, StringComparison.Ordinal);
|
||||
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -68,4 +76,23 @@ public sealed class CampaignTemplatesNavigationTests
|
||||
|
||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the <c><Version>...</Version></c> element from
|
||||
/// <c>Directory.Build.props</c>. Tolerant of whitespace, comments and
|
||||
/// attribute shuffling — the MSBuild schema for <c>Version</c> is just
|
||||
/// a plain element with a string body.
|
||||
/// </summary>
|
||||
private static string ReadVersionFromProps(string propsPath)
|
||||
{
|
||||
var doc = System.Xml.Linq.XDocument.Load(propsPath);
|
||||
var versionElement = doc.Descendants()
|
||||
.FirstOrDefault(e => e.Name.LocalName == "Version");
|
||||
Assert.NotNull(versionElement);
|
||||
var version = versionElement!.Value.Trim();
|
||||
Assert.False(
|
||||
string.IsNullOrEmpty(version),
|
||||
$"<Version> in {propsPath} is empty");
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user