Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d5dd2ed0a | |||
| 7cb5b03cc2 | |||
| 014b5edd31 | |||
| bbd58142db | |||
| 956ec01583 | |||
| 5014ca5c58 | |||
| efd86bca0a | |||
| 2241568bac | |||
| 37ed697696 | |||
| 320ec18ab0 | |||
| 4424d8faad | |||
| 1f3fb6e89e | |||
| e3e6e841b8 | |||
| a0a84965b3 | |||
| 67e8d5b558 | |||
| 593f8a62fb | |||
| aee0ac1e6c | |||
| 68945d931f | |||
| 3db2b703d6 | |||
| 3c3ef8db5a | |||
| 5c0397a5e6 | |||
| 15040eb954 | |||
| 99a58d7835 | |||
| f491727cec | |||
| 2c9016a383 | |||
| 065e8011ee | |||
| f796b7d1e4 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 3.9.0
|
VERSION: 3.10.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
@@ -72,7 +72,27 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Install Trivy
|
- name: Install Trivy
|
||||||
run: |
|
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
|
- name: Scan Bot image
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -47,7 +47,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Trivy
|
- name: Install Trivy
|
||||||
run: |
|
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
|
trivy --version
|
||||||
|
|
||||||
- name: Trivy filesystem security scan
|
- name: Trivy filesystem security scan
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.9.0</Version>
|
<Version>3.10.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
Проект разработан с упором на производительность, архитектуру Vertical Slice, Native AOT (для бота) и удобство развертывания с использованием .NET Aspire.
|
||||||
|
|
||||||
**Текущая версия:** `v3.6.0`.
|
**Текущая версия:** `v3.10.0`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Key Features
|
## ✨ Key Features
|
||||||
|
|
||||||
### 🤖 Telegram Bot
|
### 🤖 Telegram Bot
|
||||||
- **📅 Создание расписаний (Batch Sessions)**: Создавайте сразу несколько игр одним сообщением изменения (на недельный месяц в перед).
|
- **📅 Создание расписаний (Batch Sessions)**: Через `/newsession` бот ведёт ГМа по wizard: тип игры/пула, система, длительность, дата, лимит мест, формат `Online`/`Offline`, ссылка для online-игры или адрес offline-встречи, видимость и публикация.
|
||||||
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
|
- **🖼 Обложки расписаний**: И batch-посту можно прикрепить фото к `/newsession` или указать строку `Картинка: https://...`; бот отправит обложку перед сообщением записи.
|
||||||
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
- **⚡ Быстрые повторы расписания**: Для регулярной кампании можно указать одну дату, количество игр и интервал, а бот сам развернёт повторяющийся batch.
|
||||||
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
- **✋ Интерактивная запись и выход**: Игроки записываются на конкретные даты и самостоятельно снимают запись нажатием одной кнопки.
|
||||||
@@ -127,7 +127,7 @@ docker compose up -d
|
|||||||
2. Создайте группу через `/newgroup`.
|
2. Создайте группу через `/newgroup`.
|
||||||
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
3. Откройте Mini App или Web Dashboard для расширенного управления.
|
||||||
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
4. Для Discord пригласите application bot на сервер с правами `bot` и `applications.commands`. Скопируйте `DISCORD_BOT_TOKEN` в `.env`; `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` и `DISCORD_REDIRECT_URI` нужны только для входа в Web Dashboard через Discord.
|
||||||
5. Перезапустите Docker Compose (`docker compose up -d`), а затем в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`; игроки записываются и выходят кнопками в опубликованном сообщении.
|
5. Перезапустите Docker Compose (`docker compose up -d`), затем создайте расписание: в Telegram через `/newsession` выберите `Online` и URL подключения или `Offline` и адрес места проведения; в Discord создайте сессию через `/newsession` или опубликуйте расписание через `/listsessions`.
|
||||||
|
|
||||||
## 📚 Портфолио завершённых приключений
|
## 📚 Портфолио завершённых приключений
|
||||||
|
|
||||||
|
|||||||
+50
-1
@@ -1,4 +1,53 @@
|
|||||||
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
|
## 🎯 Minor 3.10.0 — Online/offline format in /newsession wizard (issue #136)
|
||||||
|
|
||||||
|
### 🧩 Что вошло в релиз
|
||||||
|
- Telegram `/newsession` wizard теперь запрашивает формат `Online` / `Offline`.
|
||||||
|
- Для `Online` мастер вводит URL подключения; для `Offline` — адрес места проведения.
|
||||||
|
- Offline-адрес сохраняется в `sessions.location_address` через миграцию `V033__add_session_location_address.sql`.
|
||||||
|
- Telegram schedule messages показывают URL online-игры или адрес offline-встречи; Web duplicate Telegram renderer синхронизирован.
|
||||||
|
|
||||||
|
### 📦 Версия и деплой
|
||||||
|
- Версия обновлена до 3.10.0 (`Directory.Build.props`, `NavMenu.razor`, `.gitea/workflows/deploy.yml`).
|
||||||
|
- Docker-образы тегируются `3.10.0` в `compose.yaml`.
|
||||||
|
|
||||||
|
## 🐞 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` глотал исключение и апдейт терялся.
|
||||||
|
|
||||||
|
### 🩹 Что починено
|
||||||
|
- `src/GmRelay.Shared/Features/Sessions/CreateSession/Wizard/WizardDraftRepository.cs` — все 4 метода переписаны с `new CommandDefinition(sql, params, cancellationToken: ct)` на прямой оверлоад `connection.QuerySingleOrDefaultAsync<WizardDraft>(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<WizardDraft>` + `CommandFactory30<WizardDraft>`.
|
||||||
|
|
||||||
|
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
|
||||||
|
|
||||||
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
|
Пошаговый сценарий создания одиночной игры или пула игр в Discord-чате, по аналогии с Telegram-визардом из 3.8.0. Платформо-нейтральная стейт-машина `GameCreationWizard` и контракт `IWizardMessenger` перенесены в `GmRelay.Shared`, чтобы обе платформы (Telegram/Discord) использовали один и тот же движок визарда.
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -49,7 +49,7 @@ services:
|
|||||||
crond -f
|
crond -f
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.10.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
discord:
|
discord:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0
|
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.10.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -86,7 +86,7 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0
|
image: git.codeanddice.ru/toutsu/gmrelay-web:3.10.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
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
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Устанавливаем wget для healthcheck
|
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends wget \
|
# и 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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Копируем только AOT-результаты из билда
|
# Копируем только AOT-результаты из билда
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
// 3. Загружаем весь батч для перерисовки
|
// 3. Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at",
|
ORDER BY scheduled_at",
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ using System.Linq;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Dapper;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession;
|
using GmRelay.Shared.Features.Sessions.CreateSession;
|
||||||
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
using Telegram.Bot.Types;
|
using Telegram.Bot.Types;
|
||||||
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
using SharedCreateSessionHandler = GmRelay.Shared.Features.Sessions.CreateSession.CreateSessionHandler;
|
||||||
|
|
||||||
@@ -31,17 +33,23 @@ public sealed class CreateSessionHandler
|
|||||||
private readonly SharedCreateSessionHandler _shared;
|
private readonly SharedCreateSessionHandler _shared;
|
||||||
private readonly IWizardMessenger _messenger;
|
private readonly IWizardMessenger _messenger;
|
||||||
private readonly ILogger<CreateSessionHandler> _log;
|
private readonly ILogger<CreateSessionHandler> _log;
|
||||||
|
private readonly IPlatformMessenger? _platformMessenger;
|
||||||
|
private readonly NpgsqlDataSource? _dataSource;
|
||||||
|
|
||||||
public CreateSessionHandler(
|
public CreateSessionHandler(
|
||||||
IWizardDraftRepository drafts,
|
IWizardDraftRepository drafts,
|
||||||
SharedCreateSessionHandler shared,
|
SharedCreateSessionHandler shared,
|
||||||
IWizardMessenger messenger,
|
IWizardMessenger messenger,
|
||||||
ILogger<CreateSessionHandler> log)
|
ILogger<CreateSessionHandler> log,
|
||||||
|
IPlatformMessenger? platformMessenger = null,
|
||||||
|
NpgsqlDataSource? dataSource = null)
|
||||||
{
|
{
|
||||||
_drafts = drafts;
|
_drafts = drafts;
|
||||||
_shared = shared;
|
_shared = shared;
|
||||||
_messenger = messenger;
|
_messenger = messenger;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_platformMessenger = platformMessenger;
|
||||||
|
_dataSource = dataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -66,16 +74,16 @@ public sealed class CreateSessionHandler
|
|||||||
OwnerId = ownerId,
|
OwnerId = ownerId,
|
||||||
Platform = PlatformName,
|
Platform = PlatformName,
|
||||||
Step = WizardStepNames.Type,
|
Step = WizardStepNames.Type,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||||
};
|
};
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
|
|
||||||
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
|
||||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||||
draft.DraftMessageId = msgId;
|
draft.DraftMessageId = msgId;
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
return draft;
|
return draft;
|
||||||
}
|
}
|
||||||
@@ -106,19 +114,24 @@ public sealed class CreateSessionHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
var commands = BuildCommands(draft, payload);
|
var commands = BuildCommands(draft, payload);
|
||||||
|
var created = new List<(CreateSessionCommand Command, CreateSessionResult Result)>();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var cmd in commands)
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -135,16 +148,96 @@ public sealed class CreateSessionHandler
|
|||||||
await _drafts.DeleteAsync(draft.Id, ct);
|
await _drafts.DeleteAsync(draft.Id, ct);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
await _messenger.EditDraftMessageAsync(
|
await _messenger.EditDraftMessageAsync(
|
||||||
draft,
|
draft,
|
||||||
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
$"💥 Ошибка: {ex.Message}\nПопытка {payload.RetryCount}/{MaxRetries}.",
|
||||||
RetryCancelActions(),
|
RetryCancelActions(),
|
||||||
ct);
|
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 ────────────────────────────────────────
|
// ── Build shared commands ────────────────────────────────────────
|
||||||
// The shared handler creates one session per scheduled time in a
|
// The shared handler creates one session per scheduled time in a
|
||||||
// single transaction and assigns the same batch_id to all of them.
|
// single transaction and assigns the same batch_id to all of them.
|
||||||
@@ -170,7 +263,7 @@ public sealed class CreateSessionHandler
|
|||||||
draft,
|
draft,
|
||||||
p,
|
p,
|
||||||
new[] { p.Single?.ScheduledAt ?? default },
|
new[] { p.Single?.ScheduledAt ?? default },
|
||||||
p.Single?.MaxPlayers ?? 0,
|
p.Single?.MaxPlayers,
|
||||||
isOneShot: true),
|
isOneShot: true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -178,11 +271,11 @@ public sealed class CreateSessionHandler
|
|||||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||||
|
|
||||||
private static CreateSessionCommand BuildCommand(
|
internal static CreateSessionCommand BuildCommand(
|
||||||
WizardDraft draft,
|
WizardDraft draft,
|
||||||
WizardPayload p,
|
WizardPayload p,
|
||||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
int maxPlayers,
|
int? maxPlayers,
|
||||||
bool isOneShot)
|
bool isOneShot)
|
||||||
{
|
{
|
||||||
var user = new PlatformUser(
|
var user = new PlatformUser(
|
||||||
@@ -200,15 +293,16 @@ public sealed class CreateSessionHandler
|
|||||||
User: user,
|
User: user,
|
||||||
Group: group,
|
Group: group,
|
||||||
Title: p.Title ?? string.Empty,
|
Title: p.Title ?? string.Empty,
|
||||||
Link: string.Empty,
|
Link: p.Format == WizardSessionFormat.Online ? p.JoinLink ?? string.Empty : string.Empty,
|
||||||
ScheduledTimes: scheduledTimes,
|
ScheduledTimes: scheduledTimes,
|
||||||
MaxPlayers: maxPlayers,
|
MaxPlayers: maxPlayers,
|
||||||
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
ImageReference: p.ImageFileId ?? p.ImageUrl,
|
||||||
System: ParseSystem(p.System),
|
System: ParseSystem(p.System),
|
||||||
Description: p.Description,
|
Description: p.Description,
|
||||||
Format: null,
|
Format: p.Format?.ToString(),
|
||||||
DurationMinutes: p.DurationMinutes,
|
DurationMinutes: p.DurationMinutes,
|
||||||
IsOneShot: isOneShot);
|
IsOneShot: isOneShot,
|
||||||
|
LocationAddress: p.Format == WizardSessionFormat.Offline ? p.LocationAddress : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GameSystem? ParseSystem(string? code)
|
private static GameSystem? ParseSystem(string? code)
|
||||||
@@ -224,12 +318,16 @@ public sealed class CreateSessionHandler
|
|||||||
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
if (string.IsNullOrWhiteSpace(p.Title)) missingFields.Add("название");
|
||||||
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
if (string.IsNullOrWhiteSpace(p.System)) missingFields.Add("система");
|
||||||
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
if (!p.DurationMinutes.HasValue) missingFields.Add("длительность");
|
||||||
|
if (p.Format is null) missingFields.Add("формат");
|
||||||
|
if (p.Format == WizardSessionFormat.Online && string.IsNullOrWhiteSpace(p.JoinLink)) missingFields.Add("ссылка");
|
||||||
|
if (p.Format == WizardSessionFormat.Offline && string.IsNullOrWhiteSpace(p.LocationAddress)) missingFields.Add("адрес");
|
||||||
if (p.Visibility is null) missingFields.Add("видимость");
|
if (p.Visibility is null) missingFields.Add("видимость");
|
||||||
|
|
||||||
if (p.Type == WizardCreationType.Single)
|
if (p.Type == WizardCreationType.Single)
|
||||||
{
|
{
|
||||||
if (p.Single?.ScheduledAt is null) missingFields.Add("дата/время");
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -139,7 +139,9 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
scheduled_at AS ScheduledAt,
|
scheduled_at AS ScheduledAt,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
max_players AS MaxPlayers,
|
max_players AS MaxPlayers,
|
||||||
join_link AS JoinLink
|
join_link AS JoinLink,
|
||||||
|
format AS Format,
|
||||||
|
location_address AS LocationAddress
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at
|
||||||
|
|||||||
@@ -82,6 +82,14 @@ public sealed class TelegramWizardMessenger(
|
|||||||
// and game_groups has no `club_id` FK). The picker therefore returns the
|
// 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
|
// game_groups the owner manages as a GM (via group_managers), matching
|
||||||
// the WizardClubOption contract (UUID id, name) used downstream.
|
// 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 = """
|
const string sql = """
|
||||||
SELECT g.id AS ClubId,
|
SELECT g.id AS ClubId,
|
||||||
g.name AS Name
|
g.name AS Name
|
||||||
@@ -95,10 +103,8 @@ public sealed class TelegramWizardMessenger(
|
|||||||
""";
|
""";
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
var rows = await connection.QueryAsync<WizardClubOption>(
|
var rows = await connection.QueryAsync<WizardClubOption>(
|
||||||
new CommandDefinition(
|
sql,
|
||||||
sql,
|
new { Platform = "Telegram", ExternalId = ownerId });
|
||||||
new { Platform = "Telegram", ExternalId = ownerId },
|
|
||||||
cancellationToken: ct));
|
|
||||||
return rows.AsList();
|
return rows.AsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -162,7 +162,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { result.BatchId })).ToList();
|
new { result.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -405,19 +405,8 @@ public sealed class TelegramPlatformMessenger(
|
|||||||
|
|
||||||
Ответьте кнопкой в групповом сообщении расписания.
|
Ответьте кнопкой в групповом сообщении расписания.
|
||||||
""",
|
""",
|
||||||
PlatformDirectSessionNotificationKind.OneHourReminder => $"""
|
PlatformDirectSessionNotificationKind.OneHourReminder => BuildOneHourReminderDirectText(notification),
|
||||||
⏰ <b>Игра начнётся примерно через 1 час</b>
|
PlatformDirectSessionNotificationKind.JoinLink => BuildJoinLinkDirectText(notification),
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
|
||||||
📅 {notification.ScheduledAt.FormatMoscow()} (МСК)
|
|
||||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
|
||||||
""",
|
|
||||||
PlatformDirectSessionNotificationKind.JoinLink => $"""
|
|
||||||
🎮 <b>Игра начинается через 5 минут</b>
|
|
||||||
|
|
||||||
📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>
|
|
||||||
🔗 {System.Net.WebUtility.HtmlEncode(notification.JoinLink ?? string.Empty)}
|
|
||||||
""",
|
|
||||||
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
PlatformDirectSessionNotificationKind.RescheduleApproved => $"""
|
||||||
✅ <b>Сессия перенесена по итогам голосования</b>
|
✅ <b>Сессия перенесена по итогам голосования</b>
|
||||||
|
|
||||||
@@ -434,6 +423,39 @@ public sealed class TelegramPlatformMessenger(
|
|||||||
_ => BuildFallbackDirectText(notification)
|
_ => BuildFallbackDirectText(notification)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string BuildOneHourReminderDirectText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"⏰ <b>Игра начнётся примерно через 1 час</b>",
|
||||||
|
string.Empty,
|
||||||
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>",
|
||||||
|
$"📅 {notification.ScheduledAt.FormatMoscow()} (МСК)"
|
||||||
|
};
|
||||||
|
AppendJoinLinkLine(lines, notification.JoinLink);
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildJoinLinkDirectText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var lines = new List<string>
|
||||||
|
{
|
||||||
|
"🎮 <b>Игра начинается через 5 минут</b>",
|
||||||
|
string.Empty,
|
||||||
|
$"📌 <b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>"
|
||||||
|
};
|
||||||
|
AppendJoinLinkLine(lines, notification.JoinLink);
|
||||||
|
return string.Join("\n", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AppendJoinLinkLine(List<string> lines, string? joinLink)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(joinLink))
|
||||||
|
{
|
||||||
|
lines.Add($"🔗 {System.Net.WebUtility.HtmlEncode(joinLink)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
|
private static string BuildFallbackDirectText(PlatformDirectSessionNotification notification) =>
|
||||||
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
|
$"<b>{System.Net.WebUtility.HtmlEncode(notification.Title)}</b>\n{notification.ScheduledAt.FormatMoscow()} (МСК)";
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ public static class TelegramSessionBatchRenderer
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||||
{
|
{
|
||||||
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
|
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
|
||||||
|
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||||
|
{
|
||||||
|
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.ActivePlayers.Count > 0)
|
if (session.ActivePlayers.Count > 0)
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN location_address TEXT;
|
||||||
@@ -145,7 +145,7 @@ public sealed class DiscordRescheduleVotingDeadlineService(
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
var sessions = (await connection.QueryAsync<SessionBatchDto>(
|
var sessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { result.BatchId })).ToList();
|
new { result.BatchId })).ToList();
|
||||||
|
|
||||||
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var participants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -31,8 +31,10 @@ internal static class DiscordPermissionLookup
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||||
|
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||||
var rows = await connection.QueryAsync<ulong>(
|
var rows = await connection.QueryAsync<ulong>(
|
||||||
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
|
sql,
|
||||||
|
new { GuildId = guildId.ToString() });
|
||||||
return rows.ToList();
|
return rows.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,9 +115,9 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
|
|||||||
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
|
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
|
||||||
? WizardStepNames.Title
|
? WizardStepNames.Title
|
||||||
: WizardStepNames.Type,
|
: WizardStepNames.Type,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||||
};
|
};
|
||||||
// If the user passed `mode=pool` we pre-seed the payload so the
|
// If the user passed `mode=pool` we pre-seed the payload so the
|
||||||
// wizard's own branching lands on the pool flow.
|
// wizard's own branching lands on the pool flow.
|
||||||
@@ -147,7 +147,7 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
|
|||||||
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
|
var (text, actions) = WizardStepViewBuilder.Build(draft, payload);
|
||||||
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
|
||||||
draft.DraftMessageId = msgId;
|
draft.DraftMessageId = msgId;
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
|
|
||||||
await Context.Interaction.ModifyResponseAsync(msg =>
|
await Context.Interaction.ModifyResponseAsync(msg =>
|
||||||
|
|||||||
@@ -537,11 +537,10 @@ internal static class WizardClubLookup
|
|||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""";
|
""";
|
||||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
// NativeAOT: direct overload — see TelegramWizardMessenger.
|
||||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||||
new CommandDefinition(
|
sql,
|
||||||
sql,
|
new { Platform = "Discord", OwnerId = ownerId });
|
||||||
new { Platform = "Discord", OwnerId = ownerId },
|
|
||||||
cancellationToken: ct));
|
|
||||||
return rows.AsList();
|
return rows.AsList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,11 +164,11 @@ public sealed class DiscordWizardMessenger : IWizardMessenger
|
|||||||
ORDER BY g.name
|
ORDER BY g.name
|
||||||
""";
|
""";
|
||||||
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
await using var conn = await _dataSource.OpenConnectionAsync(ct);
|
||||||
|
// NativeAOT: direct (sql, params) overload — see
|
||||||
|
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
|
||||||
var rows = await conn.QueryAsync<WizardClubOption>(
|
var rows = await conn.QueryAsync<WizardClubOption>(
|
||||||
new CommandDefinition(
|
sql,
|
||||||
sql,
|
new { Platform = "Discord", ExternalId = ownerId });
|
||||||
new { Platform = "Discord", ExternalId = ownerId },
|
|
||||||
cancellationToken: ct));
|
|
||||||
return rows.AsList();
|
return rows.AsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -252,11 +252,12 @@ public static class DiscordWizardStep
|
|||||||
|
|
||||||
private static DiscordWizardRender RenderCapacity() => new(
|
private static DiscordWizardRender RenderCapacity() => new(
|
||||||
"👥 Лимит мест",
|
"👥 Лимит мест",
|
||||||
"Введите лимит (1..50) и выберите waitlist.",
|
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
|
||||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
|
||||||
|
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
|
||||||
Row(ControlBtn("⬅️ Назад", "back"),
|
Row(ControlBtn("⬅️ Назад", "back"),
|
||||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||||
},
|
},
|
||||||
@@ -371,11 +372,12 @@ public static class DiscordWizardStep
|
|||||||
|
|
||||||
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
|
||||||
"👥 Лимит слотов",
|
"👥 Лимит слотов",
|
||||||
"Введите лимит (1..50) и выберите waitlist.",
|
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
|
||||||
new[]
|
new[]
|
||||||
{
|
{
|
||||||
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
|
||||||
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
|
||||||
|
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
|
||||||
Row(ControlBtn("⬅️ Назад", "back"),
|
Row(ControlBtn("⬅️ Назад", "back"),
|
||||||
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
|
||||||
},
|
},
|
||||||
@@ -417,6 +419,10 @@ public static class DiscordWizardStep
|
|||||||
{
|
{
|
||||||
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
|
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));
|
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed class DiscordWizardSubmitter
|
|||||||
_contextStore.Remove(draft.Id);
|
_contextStore.Remove(draft.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
// The full exception (with stack trace, Postgres constraint
|
// The full exception (with stack trace, Postgres constraint
|
||||||
// name, sometimes partial SQL) is already logged server-side
|
// name, sometimes partial SQL) is already logged server-side
|
||||||
@@ -134,7 +134,7 @@ public sealed class DiscordWizardSubmitter
|
|||||||
draft,
|
draft,
|
||||||
p,
|
p,
|
||||||
new[] { p.Single?.ScheduledAt ?? default },
|
new[] { p.Single?.ScheduledAt ?? default },
|
||||||
p.Single?.MaxPlayers ?? 0,
|
p.Single?.MaxPlayers,
|
||||||
isOneShot: true),
|
isOneShot: true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -142,11 +142,11 @@ public sealed class DiscordWizardSubmitter
|
|||||||
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
private static int MaxPlayersForPool(WizardPoolInput pool) =>
|
||||||
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
|
||||||
|
|
||||||
private static CreateSessionCommand BuildCommand(
|
internal static CreateSessionCommand BuildCommand(
|
||||||
WizardDraft draft,
|
WizardDraft draft,
|
||||||
WizardPayload p,
|
WizardPayload p,
|
||||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
int maxPlayers,
|
int? maxPlayers,
|
||||||
bool isOneShot)
|
bool isOneShot)
|
||||||
{
|
{
|
||||||
var user = new PlatformUser(
|
var user = new PlatformUser(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
|
||||||
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
|
||||||
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
<NoWarn>$(NoWarn);DAP005</NoWarn>
|
||||||
|
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
|
|||||||
using NetCord.Services.ComponentInteractions;
|
using NetCord.Services.ComponentInteractions;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
|
|
||||||
|
[module: Dapper.DapperAot]
|
||||||
|
|
||||||
var builder = Host.CreateApplicationBuilder(args);
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
builder.AddServiceDefaults();
|
builder.AddServiceDefaults();
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
|
||||||
@@ -57,6 +57,7 @@ public sealed class SendJoinLinkHandler(
|
|||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE s.id = @SessionId
|
WHERE s.id = @SessionId
|
||||||
AND s.status = @Confirmed
|
AND s.status = @Confirmed
|
||||||
|
AND btrim(s.join_link) <> ''
|
||||||
AND (
|
AND (
|
||||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||||
OR (
|
OR (
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ public sealed record CreateSessionCommand(
|
|||||||
string? Description = null,
|
string? Description = null,
|
||||||
string? Format = null,
|
string? Format = null,
|
||||||
int? DurationMinutes = null,
|
int? DurationMinutes = null,
|
||||||
bool IsOneShot = false);
|
bool IsOneShot = false,
|
||||||
|
string? LocationAddress = null);
|
||||||
|
|||||||
@@ -82,7 +82,13 @@ public sealed class CreateSessionHandler(
|
|||||||
AND p.external_user_id = @ExternalGmId
|
AND p.external_user_id = @ExternalGmId
|
||||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
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);
|
transaction);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -118,8 +124,8 @@ public sealed class CreateSessionHandler(
|
|||||||
{
|
{
|
||||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||||
"""
|
"""
|
||||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url)
|
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, system, description, format, duration_minutes, is_one_shot, cover_image_url, location_address)
|
||||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl)
|
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers, @System, @Description, @Format, @DurationMinutes, @IsOneShot, @CoverImageUrl, @LocationAddress)
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
@@ -136,11 +142,19 @@ public sealed class CreateSessionHandler(
|
|||||||
command.Format,
|
command.Format,
|
||||||
DurationMinutes = command.DurationMinutes,
|
DurationMinutes = command.DurationMinutes,
|
||||||
IsOneShot = command.IsOneShot,
|
IsOneShot = command.IsOneShot,
|
||||||
CoverImageUrl = command.ImageReference
|
CoverImageUrl = command.ImageReference,
|
||||||
|
command.LocationAddress
|
||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
|
sessions.Add(new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
scheduledAt.UtcDateTime,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
command.MaxPlayers,
|
||||||
|
command.Link,
|
||||||
|
command.Format,
|
||||||
|
command.LocationAddress));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public sealed class JoinSessionHandler(
|
|||||||
|
|
||||||
// Загружаем весь батч для перерисовки
|
// Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink, format as Format, location_address as LocationAddress
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at",
|
ORDER BY scheduled_at",
|
||||||
|
|||||||
@@ -161,7 +161,9 @@ public sealed class LeaveSessionHandler(
|
|||||||
scheduled_at AS ScheduledAt,
|
scheduled_at AS ScheduledAt,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
max_players AS MaxPlayers,
|
max_players AS MaxPlayers,
|
||||||
join_link AS JoinLink
|
join_link AS JoinLink,
|
||||||
|
format AS Format,
|
||||||
|
location_address AS LocationAddress
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
|
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow;
|
draft.UpdatedAt = DateTime.UtcNow;
|
||||||
await _drafts.UpsertAsync(draft, ct);
|
await _drafts.UpsertAsync(draft, ct);
|
||||||
var payload = LoadPayload(draft);
|
var payload = LoadPayload(draft);
|
||||||
IReadOnlyList<WizardClubOption>? clubs = null;
|
IReadOnlyList<WizardClubOption>? clubs = null;
|
||||||
@@ -226,9 +226,20 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null:
|
||||||
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
|
return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity
|
||||||
? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload)
|
? (WizardStepNames.Format, SetMaxPlayers(payload, cap), payload)
|
||||||
: (null, "Лимит должен быть 1..50", payload);
|
: (null, "Лимит должен быть 1..50", payload);
|
||||||
|
|
||||||
|
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Online:
|
||||||
|
return Uri.TryCreate(input.Trim(), UriKind.Absolute, out var locationUri) &&
|
||||||
|
(locationUri.Scheme == Uri.UriSchemeHttp || locationUri.Scheme == Uri.UriSchemeHttps)
|
||||||
|
? (WizardStepNames.Visibility, SetJoinLink(payload, input.Trim()), payload)
|
||||||
|
: (null, "Некорректная ссылка", payload);
|
||||||
|
|
||||||
|
case WizardStepNames.Location when payload.Format == WizardSessionFormat.Offline:
|
||||||
|
return ValidateText(input, WizardStepLimits.MaxLocationLength, "Адрес не может быть пустым", "Слишком длинный адрес", out var address)
|
||||||
|
? (WizardStepNames.Visibility, SetLocationAddress(payload, address), payload)
|
||||||
|
: (null, address, payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
case WizardStepNames.PoolSystemDuration when payload.System is null:
|
||||||
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys)
|
||||||
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload)
|
||||||
@@ -236,7 +247,7 @@ public sealed class GameCreationWizard
|
|||||||
|
|
||||||
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null:
|
||||||
return TryParseHours(input, out var pdur)
|
return TryParseHours(input, out var pdur)
|
||||||
? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload)
|
? (WizardStepNames.Format, SetDurationMinutes(payload, pdur), payload)
|
||||||
: (null, "Неверная длительность (1..12 ч)", payload);
|
: (null, "Неверная длительность (1..12 ч)", payload);
|
||||||
|
|
||||||
case WizardStepNames.PoolSlotDateTime:
|
case WizardStepNames.PoolSlotDateTime:
|
||||||
@@ -264,6 +275,7 @@ public sealed class GameCreationWizard
|
|||||||
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
WizardStepNames.System => ApplySystemChoice(payload, choice),
|
||||||
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
|
WizardStepNames.Duration => ApplyDurationChoice(payload, choice),
|
||||||
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
|
WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice),
|
||||||
|
WizardStepNames.Format => ApplyFormatChoice(payload, choice),
|
||||||
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
|
WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice),
|
||||||
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
|
WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice),
|
||||||
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
|
WizardStepNames.Publish => ApplyPublishChoice(payload, choice),
|
||||||
@@ -298,10 +310,30 @@ public sealed class GameCreationWizard
|
|||||||
: (null, "Неверная длительность"),
|
: (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)),
|
if (choice is "no_limit")
|
||||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
{
|
||||||
|
return (WizardStepNames.Format, SetMaxPlayers(p, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
|
||||||
|
{
|
||||||
|
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
|
||||||
|
}
|
||||||
|
|
||||||
|
return choice switch
|
||||||
|
{
|
||||||
|
"waitlist:on" => (WizardStepNames.Format, SetWaitlist(p, true)),
|
||||||
|
"waitlist:off" => (WizardStepNames.Format, SetWaitlist(p, false)),
|
||||||
|
_ => (null, "Неизвестный выбор"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string?, string?) ApplyFormatChoice(WizardPayload p, string choice) => choice switch
|
||||||
|
{
|
||||||
|
"online" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Online)),
|
||||||
|
"offline" => (WizardStepNames.Location, SetFormat(p, WizardSessionFormat.Offline)),
|
||||||
_ => (null, "Неизвестный выбор"),
|
_ => (null, "Неизвестный выбор"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,9 +347,15 @@ public sealed class GameCreationWizard
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
|
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
|
||||||
=> Guid.TryParse(choice, out var id)
|
{
|
||||||
? (NextAfterVisibility(p), SetClubId(p, id))
|
if (!Guid.TryParse(choice, out var id))
|
||||||
: (null, "Неверный идентификатор клуба");
|
{
|
||||||
|
return (null, "Неверный идентификатор клуба");
|
||||||
|
}
|
||||||
|
|
||||||
|
var error = SetClubId(p, id);
|
||||||
|
return (NextAfterVisibility(p), error);
|
||||||
|
}
|
||||||
|
|
||||||
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
|
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
|
||||||
{
|
{
|
||||||
@@ -330,7 +368,7 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
"_custom" => (WizardStepNames.PoolSystemDuration, null),
|
||||||
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
|
{ } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur)
|
||||||
? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
|
? (WizardStepNames.Format, SetSystem(p, sys) ?? SetDurationMinutes(p, dur))
|
||||||
: (null, "Неверный выбор"),
|
: (null, "Неверный выбор"),
|
||||||
_ => (null, "Неизвестный выбор"),
|
_ => (null, "Неизвестный выбор"),
|
||||||
};
|
};
|
||||||
@@ -372,13 +410,15 @@ public sealed class GameCreationWizard
|
|||||||
WizardStepNames.Duration => WizardStepNames.System,
|
WizardStepNames.Duration => WizardStepNames.System,
|
||||||
WizardStepNames.DateTime => WizardStepNames.Duration,
|
WizardStepNames.DateTime => WizardStepNames.Duration,
|
||||||
WizardStepNames.Capacity => WizardStepNames.DateTime,
|
WizardStepNames.Capacity => WizardStepNames.DateTime,
|
||||||
WizardStepNames.Visibility => WizardStepNames.Capacity,
|
WizardStepNames.Format => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.Capacity,
|
||||||
|
WizardStepNames.Location => WizardStepNames.Format,
|
||||||
|
WizardStepNames.Visibility => WizardStepNames.Location,
|
||||||
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
WizardStepNames.PickClub => WizardStepNames.Visibility,
|
||||||
WizardStepNames.Publish => WizardStepNames.PickClub,
|
WizardStepNames.Publish => WizardStepNames.PickClub,
|
||||||
WizardStepNames.Confirm => WizardStepNames.Publish,
|
WizardStepNames.Confirm => WizardStepNames.Publish,
|
||||||
|
|
||||||
WizardStepNames.PoolSystemDuration => null, // first pool step
|
WizardStepNames.PoolSystemDuration => null, // first pool step
|
||||||
WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration,
|
WizardStepNames.PoolAddSlots => WizardStepNames.Visibility,
|
||||||
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
|
WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots,
|
||||||
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
|
WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime,
|
||||||
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
|
WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots,
|
||||||
@@ -416,13 +456,22 @@ public sealed class GameCreationWizard
|
|||||||
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
||||||
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
|
||||||
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
|
{ 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; }
|
{ 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? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
|
||||||
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
|
||||||
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
|
private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; }
|
||||||
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
|
private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; }
|
||||||
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
|
private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; }
|
||||||
|
private static string? SetFormat(WizardPayload p, WizardSessionFormat v)
|
||||||
|
{
|
||||||
|
p.Format = v;
|
||||||
|
p.JoinLink = null;
|
||||||
|
p.LocationAddress = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
private static string? SetJoinLink(WizardPayload p, string v) { p.JoinLink = v; p.LocationAddress = null; return null; }
|
||||||
|
private static string? SetLocationAddress(WizardPayload p, string v) { p.LocationAddress = v; p.JoinLink = null; return null; }
|
||||||
|
|
||||||
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
|
private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v)
|
||||||
{
|
{
|
||||||
@@ -469,8 +518,8 @@ public sealed class GameCreationWizard
|
|||||||
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
|
private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration;
|
||||||
private static string? NextAfterDuration(WizardPayload p)
|
private static string? NextAfterDuration(WizardPayload p)
|
||||||
{
|
{
|
||||||
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility;
|
if (p.Type == WizardCreationType.Pool) return WizardStepNames.Format;
|
||||||
return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime;
|
return p.Single?.MaxPlayers is not null ? WizardStepNames.Format : WizardStepNames.DateTime;
|
||||||
}
|
}
|
||||||
private static string? NextAfterVisibility(WizardPayload p)
|
private static string? NextAfterVisibility(WizardPayload p)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ public sealed class WizardDraft
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? DraftMessageId { get; set; }
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
|||||||
|
|
||||||
public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizardDraftRepository
|
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<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
public async Task<WizardDraft?> GetActiveAsync(string platform, string ownerId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
@@ -29,13 +36,10 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
|||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
|
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
|
||||||
new CommandDefinition(
|
sql,
|
||||||
sql,
|
new { Platform = platform, OwnerId = ownerId });
|
||||||
new { Platform = platform, OwnerId = ownerId },
|
|
||||||
cancellationToken: ct));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
|
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
|
||||||
@@ -52,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
|
|||||||
updated_at = EXCLUDED.updated_at,
|
updated_at = EXCLUDED.updated_at,
|
||||||
expires_at = EXCLUDED.expires_at;
|
expires_at = EXCLUDED.expires_at;
|
||||||
""";
|
""";
|
||||||
|
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
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)
|
public async Task DeleteAsync(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
|
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
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<int> DeleteExpiredAsync(CancellationToken ct)
|
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
|
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
|
return await connection.ExecuteAsync(sql);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ public enum WizardCreationType { Single, Pool }
|
|||||||
|
|
||||||
public enum WizardVisibility { Public, Club, Members }
|
public enum WizardVisibility { Public, Club, Members }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<WizardSessionFormat>))]
|
||||||
|
public enum WizardSessionFormat { Online, Offline }
|
||||||
|
|
||||||
public sealed class WizardSlotInput
|
public sealed class WizardSlotInput
|
||||||
{
|
{
|
||||||
public DateTimeOffset ScheduledAt { get; set; }
|
public DateTimeOffset ScheduledAt { get; set; }
|
||||||
@@ -30,6 +33,9 @@ public sealed class WizardPayload
|
|||||||
public string? ImageUrl { get; set; }
|
public string? ImageUrl { get; set; }
|
||||||
public string? System { get; set; }
|
public string? System { get; set; }
|
||||||
public int? DurationMinutes { get; set; }
|
public int? DurationMinutes { get; set; }
|
||||||
|
public WizardSessionFormat? Format { get; set; }
|
||||||
|
public string? JoinLink { get; set; }
|
||||||
|
public string? LocationAddress { get; set; }
|
||||||
public WizardVisibility? Visibility { get; set; }
|
public WizardVisibility? Visibility { get; set; }
|
||||||
public Guid? ClubId { get; set; }
|
public Guid? ClubId { get; set; }
|
||||||
public bool? PublishInShowcase { get; set; }
|
public bool? PublishInShowcase { get; set; }
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ public static class WizardStepLimits
|
|||||||
public const int MinCapacity = 1;
|
public const int MinCapacity = 1;
|
||||||
public const int MinDurationHours = 1;
|
public const int MinDurationHours = 1;
|
||||||
public const int MaxDurationHours = 12;
|
public const int MaxDurationHours = 12;
|
||||||
|
public const int MaxLocationLength = 500;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public static class WizardStepNames
|
|||||||
public const string Duration = "Duration";
|
public const string Duration = "Duration";
|
||||||
public const string DateTime = "DateTime";
|
public const string DateTime = "DateTime";
|
||||||
public const string Capacity = "Capacity";
|
public const string Capacity = "Capacity";
|
||||||
|
public const string Format = "Format";
|
||||||
|
public const string Location = "Location";
|
||||||
public const string Visibility = "Visibility";
|
public const string Visibility = "Visibility";
|
||||||
public const string PickClub = "PickClub";
|
public const string PickClub = "PickClub";
|
||||||
public const string Publish = "Publish";
|
public const string Publish = "Publish";
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ public static class WizardStepViewBuilder
|
|||||||
WizardStepNames.Duration => BuildDuration(),
|
WizardStepNames.Duration => BuildDuration(),
|
||||||
WizardStepNames.DateTime => BuildDateTime(),
|
WizardStepNames.DateTime => BuildDateTime(),
|
||||||
WizardStepNames.Capacity => BuildCapacity(),
|
WizardStepNames.Capacity => BuildCapacity(),
|
||||||
|
WizardStepNames.Format => BuildFormat(),
|
||||||
|
WizardStepNames.Location => BuildLocation(payload),
|
||||||
WizardStepNames.Visibility => BuildVisibility(),
|
WizardStepNames.Visibility => BuildVisibility(),
|
||||||
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
WizardStepNames.PickClub => BuildPickClub(clubs ?? Array.Empty<WizardClubOption>()),
|
||||||
WizardStepNames.Publish => BuildPublish(),
|
WizardStepNames.Publish => BuildPublish(),
|
||||||
@@ -97,13 +99,30 @@ public static class WizardStepViewBuilder
|
|||||||
BackCancel());
|
BackCancel());
|
||||||
|
|
||||||
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
|
||||||
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
|
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
|
||||||
new List<WizardAction>
|
new List<WizardAction>
|
||||||
{
|
{
|
||||||
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
|
||||||
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
|
||||||
|
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildFormat() => (
|
||||||
|
"🧭 Выберите формат игры.",
|
||||||
|
new List<WizardAction>
|
||||||
|
{
|
||||||
|
new("🌐 Online", WizardCallbackData.Choice(WizardStepNames.Format, "online"), WizardActionStyle.Primary),
|
||||||
|
new("📍 Offline", WizardCallbackData.Choice(WizardStepNames.Format, "offline"), WizardActionStyle.Primary),
|
||||||
|
new("⬅️ Назад", WizardCallbackData.Back()),
|
||||||
|
new("❌ Отмена", WizardCallbackData.Cancel(), WizardActionStyle.Danger),
|
||||||
|
});
|
||||||
|
|
||||||
|
private static (string, IReadOnlyList<WizardAction>) BuildLocation(WizardPayload payload) => payload.Format switch
|
||||||
|
{
|
||||||
|
WizardSessionFormat.Offline => ("📍 Введите адрес места проведения.", BackCancel()),
|
||||||
|
_ => ("🔗 Введите ссылку для подключения к online-игре.", BackCancel()),
|
||||||
|
};
|
||||||
|
|
||||||
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||||
"🔒 Выберите видимость.",
|
"🔒 Выберите видимость.",
|
||||||
new List<WizardAction>
|
new List<WizardAction>
|
||||||
@@ -149,6 +168,7 @@ public static class WizardStepViewBuilder
|
|||||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||||
|
AppendFormatLocation(sb, p);
|
||||||
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
|
if (p.Single?.ScheduledAt is { } at) sb.AppendLine($"📅 {at.FormatMoscow()} (МСК)");
|
||||||
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
|
if (p.Single?.MaxPlayers is { } mp) sb.AppendLine($"👥 Мест: {mp}, waitlist {(p.Waitlist == true ? "вкл" : "выкл")}");
|
||||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||||
@@ -203,6 +223,7 @@ public static class WizardStepViewBuilder
|
|||||||
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
if (!string.IsNullOrEmpty(p.Description)) sb.AppendLine($"📄 {p.Description}");
|
||||||
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
if (!string.IsNullOrEmpty(p.System)) sb.AppendLine($"🎲 Система: {p.System}");
|
||||||
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
if (p.DurationMinutes.HasValue) sb.AppendLine($"⏱ Длительность: {p.DurationMinutes / 60} ч");
|
||||||
|
AppendFormatLocation(sb, p);
|
||||||
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
sb.AppendLine($"🔒 Видимость: {RenderVisibilityText(p.Visibility)}");
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
sb.AppendLine($"Слоты ({p.Pool?.Slots.Count ?? 0}):");
|
||||||
@@ -244,4 +265,19 @@ public static class WizardStepViewBuilder
|
|||||||
WizardVisibility.Members => "только для членов клуба",
|
WizardVisibility.Members => "только для членов клуба",
|
||||||
_ => "не задана",
|
_ => "не задана",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static void AppendFormatLocation(StringBuilder sb, WizardPayload p)
|
||||||
|
{
|
||||||
|
if (p.Format is null) return;
|
||||||
|
|
||||||
|
sb.AppendLine($"🧭 Формат: {p.Format}");
|
||||||
|
if (p.Format == WizardSessionFormat.Online && !string.IsNullOrWhiteSpace(p.JoinLink))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"🔗 Ссылка: {p.JoinLink}");
|
||||||
|
}
|
||||||
|
else if (p.Format == WizardSessionFormat.Offline && !string.IsNullOrWhiteSpace(p.LocationAddress))
|
||||||
|
{
|
||||||
|
sb.AppendLine($"📍 Адрес: {p.LocationAddress}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -159,7 +159,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
await transaction.CommitAsync(ct);
|
await transaction.CommitAsync(ct);
|
||||||
|
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { proposal.BatchId })).ToList();
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ public sealed class DbSessionTriggerStore(
|
|||||||
JOIN game_groups g ON g.id = s.group_id
|
JOIN game_groups g ON g.id = s.group_id
|
||||||
WHERE g.platform = @Platform
|
WHERE g.platform = @Platform
|
||||||
AND s.status = @Confirmed
|
AND s.status = @Confirmed
|
||||||
|
AND btrim(s.join_link) <> ''
|
||||||
AND s.scheduled_at - @LeadTime <= @Now
|
AND s.scheduled_at - @LeadTime <= @Now
|
||||||
AND (
|
AND (
|
||||||
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
(g.platform = 'Telegram' AND s.link_message_id IS NULL)
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
namespace GmRelay.Shared.Rendering;
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink);
|
public sealed record SessionBatchDto(
|
||||||
|
Guid SessionId,
|
||||||
|
DateTime ScheduledAt,
|
||||||
|
string Status,
|
||||||
|
int? MaxPlayers,
|
||||||
|
string JoinLink,
|
||||||
|
string? Format = null,
|
||||||
|
string? LocationAddress = null);
|
||||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ public static class SessionBatchViewBuilder
|
|||||||
session.Status,
|
session.Status,
|
||||||
session.MaxPlayers,
|
session.MaxPlayers,
|
||||||
session.JoinLink,
|
session.JoinLink,
|
||||||
|
session.Format,
|
||||||
|
session.LocationAddress,
|
||||||
activePlayers.Count,
|
activePlayers.Count,
|
||||||
activePlayers,
|
activePlayers,
|
||||||
waitlistedPlayers,
|
waitlistedPlayers,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ public sealed record SessionViewItem(
|
|||||||
string Status,
|
string Status,
|
||||||
int? MaxPlayers,
|
int? MaxPlayers,
|
||||||
string JoinLink,
|
string JoinLink,
|
||||||
|
string? Format,
|
||||||
|
string? LocationAddress,
|
||||||
int ActivePlayerCount,
|
int ActivePlayerCount,
|
||||||
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
||||||
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.9.0</div>
|
<div class="nav-version">v3.10.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
|
|||||||
@@ -1897,7 +1897,7 @@ public sealed class SessionService(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
|
||||||
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink, format AS Format, location_address AS LocationAddress FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { BatchId = batchId })).ToList();
|
new { BatchId = batchId })).ToList();
|
||||||
|
|
||||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -22,7 +22,14 @@ public static class TelegramSessionBatchRenderer
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(session.JoinLink))
|
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||||
{
|
{
|
||||||
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
|
var encodedLink = System.Net.WebUtility.HtmlEncode(session.JoinLink);
|
||||||
|
messageText += $"🔗 Ссылка на игру: <a href=\"{encodedLink}\">{encodedLink}</a>\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(session.Format, "Offline", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!string.IsNullOrWhiteSpace(session.LocationAddress))
|
||||||
|
{
|
||||||
|
messageText += $"📍 Адрес: {System.Net.WebUtility.HtmlEncode(session.LocationAddress)}\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.ActivePlayers.Count > 0)
|
if (session.ActivePlayers.Count > 0)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+192
@@ -0,0 +1,192 @@
|
|||||||
|
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",
|
||||||
|
"https://vtt.example/game",
|
||||||
|
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
GameSystem.Dnd5e,
|
||||||
|
"Integration regression test",
|
||||||
|
"Online",
|
||||||
|
240,
|
||||||
|
true,
|
||||||
|
"Online room notes"),
|
||||||
|
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);
|
||||||
|
|
||||||
|
await using var sessionCommand = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT join_link, format, location_address
|
||||||
|
FROM sessions
|
||||||
|
WHERE batch_id = @batch_id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
sessionCommand.Parameters.AddWithValue("batch_id", result.BatchId.Value);
|
||||||
|
|
||||||
|
await using var reader = await sessionCommand.ExecuteReaderAsync();
|
||||||
|
Assert.True(await reader.ReadAsync());
|
||||||
|
Assert.Equal("https://vtt.example/game", reader.GetString(0));
|
||||||
|
Assert.Equal("Online", reader.GetString(1));
|
||||||
|
Assert.Equal("Online room notes", reader.GetString(2));
|
||||||
|
Assert.False(await reader.ReadAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HandleAsync_OfflineSession_PersistsFormatAndLocationAddress()
|
||||||
|
{
|
||||||
|
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, "333333333", "Offline GM", "offline_gm"),
|
||||||
|
new PlatformGroup(PlatformKind.Telegram, "444444444", "Offline Group"),
|
||||||
|
"Offline Adventure",
|
||||||
|
string.Empty,
|
||||||
|
[DateTimeOffset.UtcNow.AddDays(1)],
|
||||||
|
4,
|
||||||
|
null,
|
||||||
|
GameSystem.Dnd5e,
|
||||||
|
"Offline integration regression test",
|
||||||
|
"Offline",
|
||||||
|
240,
|
||||||
|
true,
|
||||||
|
"Москва, ул. Кубиков, 12"),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Success, result.ErrorMessage);
|
||||||
|
Assert.NotNull(result.BatchId);
|
||||||
|
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync();
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
SELECT join_link, format, location_address
|
||||||
|
FROM sessions
|
||||||
|
WHERE batch_id = @batch_id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("batch_id", result.BatchId.Value);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync();
|
||||||
|
Assert.True(await reader.ReadAsync());
|
||||||
|
Assert.Equal(string.Empty, reader.GetString(0));
|
||||||
|
Assert.Equal("Offline", reader.GetString(1));
|
||||||
|
Assert.Equal("Москва, ул. Кубиков, 12", reader.GetString(2));
|
||||||
|
Assert.False(await reader.ReadAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenFormatIsOnline_PropagatesFormatAndJoinLink()
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal("Online", cmd.Format);
|
||||||
|
Assert.Equal("https://vtt.example/game", cmd.Link);
|
||||||
|
Assert.Null(cmd.LocationAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenFormatIsOffline_PropagatesFormatAndAddress()
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
Format = WizardSessionFormat.Offline,
|
||||||
|
LocationAddress = "Москва, ул. Кубиков, 12",
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal("Offline", cmd.Format);
|
||||||
|
Assert.Equal(string.Empty, cmd.Link);
|
||||||
|
Assert.Equal("Москва, ул. Кубиков, 12", cmd.LocationAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-14
@@ -1,19 +1,107 @@
|
|||||||
using System;
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
using Xunit;
|
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;
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
/// <summary>
|
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||||
/// Happy-path coverage for <see cref="Features.Sessions.CreateSession.CreateSessionHandler.SubmitDraftAsync"/>
|
public sealed class CreateSessionHandlerSubmitSingleDraftTests(CreateSessionHandlerPostgresFixture fixture)
|
||||||
/// 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
|
|
||||||
{
|
{
|
||||||
[Fact(Skip = "Happy-path SubmitDraftAsync needs a Testcontainers-backed PostgreSQL with the production schema; see file-level summary.")]
|
[Fact]
|
||||||
public void SubmitDraftAsync_CompleteSinglePayload_CreatesOneSession() =>
|
public async Task SubmitDraftAsync_CompleteSinglePayload_PublishesScheduleAndStoresMessageRefs()
|
||||||
throw new NotImplementedException("See Skip reason above.");
|
{
|
||||||
|
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,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+53
@@ -36,6 +36,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Title = "T",
|
Title = "T",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Single = new WizardSingleInput
|
Single = new WizardSingleInput
|
||||||
{
|
{
|
||||||
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(7),
|
||||||
@@ -69,6 +71,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Type = WizardCreationType.Single,
|
Type = WizardCreationType.Single,
|
||||||
Title = "T",
|
Title = "T",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Visibility = WizardVisibility.Public,
|
Visibility = WizardVisibility.Public,
|
||||||
Single = new WizardSingleInput
|
Single = new WizardSingleInput
|
||||||
{
|
{
|
||||||
@@ -104,6 +108,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Title = "T",
|
Title = "T",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Visibility = WizardVisibility.Public,
|
Visibility = WizardVisibility.Public,
|
||||||
Single = new WizardSingleInput { MaxPlayers = 4 },
|
Single = new WizardSingleInput { MaxPlayers = 4 },
|
||||||
};
|
};
|
||||||
@@ -135,6 +141,8 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Title = "P",
|
Title = "P",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
Visibility = WizardVisibility.Public,
|
Visibility = WizardVisibility.Public,
|
||||||
Pool = new WizardPoolInput(),
|
Pool = new WizardPoolInput(),
|
||||||
};
|
};
|
||||||
@@ -146,4 +154,49 @@ public sealed class CreateSessionHandlerSubmitValidationTests
|
|||||||
Assert.Single(messenger.Edits);
|
Assert.Single(messenger.Edits);
|
||||||
Assert.Contains("слоты", messenger.Edits[0].Text, StringComparison.OrdinalIgnoreCase);
|
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,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -84,17 +84,24 @@ public sealed class GameCreationWizardCancelBackTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Back_FromPoolAddSlots_GoesToPoolSystemDuration()
|
public async Task Back_FromPoolAddSlots_GoesToVisibility()
|
||||||
{
|
{
|
||||||
var wizard = BuildWizard(out var drafts, out _);
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var draft = NewDraft(WizardStepNames.PoolAddSlots,
|
var draft = NewDraft(WizardStepNames.PoolAddSlots,
|
||||||
new WizardPayload { Type = WizardCreationType.Pool, Title = "Pool" });
|
new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Pool,
|
||||||
|
Title = "Pool",
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
});
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
|
|
||||||
var data = WizardCallbackData.Back();
|
var data = WizardCallbackData.Back();
|
||||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.PoolSystemDuration, draft.Step);
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+124
-20
@@ -20,9 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
|
||||||
// Duration → DateTime (single, no maxPlayers yet)
|
// Duration → DateTime (single, no maxPlayers yet)
|
||||||
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
|
||||||
// Capacity → Visibility
|
// Capacity → Format (only explicit no-limit can skip numeric capacity)
|
||||||
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
|
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Format)]
|
||||||
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
|
||||||
// Visibility → Publish (public, no club)
|
// Visibility → Publish (public, no club)
|
||||||
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
|
||||||
// Visibility → PickClub
|
// Visibility → PickClub
|
||||||
@@ -47,7 +46,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PoolSystemDuration_PreselectedButton_AdvancesToVisibility()
|
public async Task PoolSystemDuration_PreselectedButton_AdvancesToFormat()
|
||||||
{
|
{
|
||||||
var wizard = BuildWizard(out var drafts, out _);
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var payload = new WizardPayload
|
var payload = new WizardPayload
|
||||||
@@ -61,7 +60,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
var data = WizardCallbackData.Choice(WizardStepNames.PoolSystemDuration, "Dnd5e:240");
|
||||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
Assert.Equal(WizardStepNames.Format, draft.Step);
|
||||||
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
var root = doc.RootElement;
|
var root = doc.RootElement;
|
||||||
Assert.True(root.TryGetProperty("system", out var sys));
|
Assert.True(root.TryGetProperty("system", out var sys));
|
||||||
@@ -71,13 +70,37 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
// (not the draft's current step) to drive transitions. So a stale
|
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
|
||||||
// "Capacity" button pressed while the user is on System will in fact
|
drafts.Seed(draft);
|
||||||
// move the draft forward as if they had pressed it on Capacity. We
|
|
||||||
// lock that behaviour in.
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Format, 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 wizard = BuildWizard(out var drafts, out _);
|
||||||
var draft = NewDraft(WizardStepNames.System);
|
var draft = NewDraft(WizardStepNames.System);
|
||||||
drafts.Seed(draft);
|
drafts.Seed(draft);
|
||||||
@@ -85,17 +108,85 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
|
||||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
Assert.Equal(WizardStepNames.System, draft.Step);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PickClub_ValidGuid_ReachesStableStep()
|
public async Task Format_OnlineChoice_AdvancesToLocationAndPersistsFormat()
|
||||||
{
|
{
|
||||||
// The wizard has a quirk: NextAfterVisibility is evaluated before
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
// SetClubId, so a single click leaves the draft still on PickClub.
|
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
||||||
// We assert that the wizard does NOT throw and the messenger is asked
|
drafts.Seed(draft);
|
||||||
// to re-render (i.e. the handler ran end-to-end).
|
|
||||||
var wizard = BuildWizard(out var drafts, out var messenger);
|
var data = WizardCallbackData.Choice(WizardStepNames.Format, "online");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Location, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("format", out var format));
|
||||||
|
Assert.Equal("Online", format.GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Format_OfflineChoice_AdvancesToLocationAndPersistsFormat()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var draft = NewDraft(WizardStepNames.Format, PayloadForStep(WizardStepNames.Format));
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
var data = WizardCallbackData.Choice(WizardStepNames.Format, "offline");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Location, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("format", out var format));
|
||||||
|
Assert.Equal("Offline", format.GetString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Location_TextForOnline_StoresJoinLinkAndAdvancesToVisibility()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var payload = PayloadForStep(WizardStepNames.Location);
|
||||||
|
payload.Format = WizardSessionFormat.Online;
|
||||||
|
var draft = NewDraft(WizardStepNames.Location, payload);
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await wizard.HandleInteractionAsync(TextInteraction("https://vtt.example/game", 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("joinLink", out var joinLink));
|
||||||
|
Assert.Equal("https://vtt.example/game", joinLink.GetString());
|
||||||
|
Assert.False(root.TryGetProperty("locationAddress", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Location_TextForOffline_StoresAddressAndAdvancesToVisibility()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var payload = PayloadForStep(WizardStepNames.Location);
|
||||||
|
payload.Format = WizardSessionFormat.Offline;
|
||||||
|
var draft = NewDraft(WizardStepNames.Location, payload);
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
await wizard.HandleInteractionAsync(TextInteraction("Москва, ул. Кубиков, 12", 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("locationAddress", out var address));
|
||||||
|
Assert.Equal("Москва, ул. Кубиков, 12", address.GetString());
|
||||||
|
Assert.False(root.TryGetProperty("joinLink", out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
var clubId = Guid.NewGuid();
|
var clubId = Guid.NewGuid();
|
||||||
var payload = new WizardPayload
|
var payload = new WizardPayload
|
||||||
{
|
{
|
||||||
@@ -111,8 +202,11 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
|
||||||
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
|
Assert.Equal(WizardStepNames.Publish, draft.Step);
|
||||||
Assert.NotEmpty(messenger.Edits);
|
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]
|
[Fact]
|
||||||
@@ -160,6 +254,16 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
Title = "T",
|
Title = "T",
|
||||||
System = "Dnd5e",
|
System = "Dnd5e",
|
||||||
DurationMinutes = 240,
|
DurationMinutes = 240,
|
||||||
|
Format = WizardSessionFormat.Online,
|
||||||
|
JoinLink = "https://vtt.example/game",
|
||||||
|
},
|
||||||
|
WizardStepNames.Format or WizardStepNames.Location => new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
|
||||||
},
|
},
|
||||||
WizardStepNames.PickClub => new WizardPayload
|
WizardStepNames.PickClub => new WizardPayload
|
||||||
{
|
{
|
||||||
|
|||||||
+23
@@ -136,6 +136,29 @@ public sealed class GameCreationWizardValidationTests
|
|||||||
Assert.Equal(WizardStepNames.Capacity, draft.Step);
|
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]
|
[Theory]
|
||||||
[InlineData("0")]
|
[InlineData("0")]
|
||||||
[InlineData("13")]
|
[InlineData("13")]
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression guard: WizardDraftRepository must not use the (CommandDefinition)
|
||||||
|
/// overload of Dapper. Dapper.AOT 1.0.48 only generates interceptors for the
|
||||||
|
/// (sql, object?) extension overloads; using CommandDefinition falls back to
|
||||||
|
/// Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
|
||||||
|
/// throws PlatformNotSupportedException on NativeAOT (v3.9.0 wizard regression).
|
||||||
|
/// The v3.9.1 hotfix switched all four methods to the direct overload.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class WizardDraftRepositoryAotShapeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void WizardDraftRepository_GetActiveAsync_ShouldNotUseCommandDefinition()
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(
|
||||||
|
repoRoot,
|
||||||
|
"src",
|
||||||
|
"GmRelay.Shared",
|
||||||
|
"Features",
|
||||||
|
"Sessions",
|
||||||
|
"CreateSession",
|
||||||
|
"Wizard",
|
||||||
|
"WizardDraftRepository.cs"));
|
||||||
|
|
||||||
|
var getActive = ExtractMethodBody(source, "GetActiveAsync", "");
|
||||||
|
Assert.DoesNotContain("new CommandDefinition", getActive, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("UpsertAsync")]
|
||||||
|
[InlineData("DeleteAsync")]
|
||||||
|
[InlineData("DeleteExpiredAsync")]
|
||||||
|
public void WizardDraftRepository_MutatingMethods_ShouldNotUseCommandDefinition(string methodName)
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(
|
||||||
|
repoRoot,
|
||||||
|
"src",
|
||||||
|
"GmRelay.Shared",
|
||||||
|
"Features",
|
||||||
|
"Sessions",
|
||||||
|
"CreateSession",
|
||||||
|
"Wizard",
|
||||||
|
"WizardDraftRepository.cs"));
|
||||||
|
|
||||||
|
var body = ExtractMethodBody(source, methodName, "");
|
||||||
|
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// WizardDraftRepository was the only AOT-fatal site in v3.9.0, but the
|
||||||
|
/// same pattern (CommandDefinition on a Dapper extension that the AOT
|
||||||
|
/// generator cannot reach) is repeated in 4 club-picker / permission
|
||||||
|
/// lookups across Telegram and Discord messengers. v3.9.2 hotfix
|
||||||
|
/// converted them all to the direct (sql, params) overload. Lock the
|
||||||
|
/// regression so the next refactor doesn't reintroduce it.
|
||||||
|
/// </summary>
|
||||||
|
[Theory]
|
||||||
|
[InlineData("src/GmRelay.Bot/Features/Sessions/CreateSession/Wizard/TelegramWizardMessenger.cs", "GetOwnerClubsAsync", "")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardMessenger.cs", "GetOwnerClubsAsync", "")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordWizardInteractionModule.cs", "LoadClubsAsync", "internal static class WizardClubLookup")]
|
||||||
|
[InlineData("src/GmRelay.DiscordBot/Features/Sessions/Wizard/DiscordPermissionLookup.cs", "LoadManagerUserIdsAsync", "")]
|
||||||
|
public void ClubPickerAndPermissionLookups_ShouldNotUseCommandDefinition(string relativePath, string methodName, string containingClass)
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||||
|
|
||||||
|
var body = ExtractMethodBody(source, methodName, containingClass);
|
||||||
|
Assert.DoesNotContain("new CommandDefinition", body, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AOT RowFactory for WizardDraft expects to read timestamps as
|
||||||
|
/// <see cref="DateTime"/> (UTC). If a future refactor switches the
|
||||||
|
/// properties back to <see cref="DateTimeOffset"/>, the AOT row factory
|
||||||
|
/// will throw <c>InvalidCastException: DateTime → DateTimeOffset</c>
|
||||||
|
/// on the first query against wizard_drafts.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void WizardDraft_TimestampsMustBeDateTime_NotDateTimeOffset()
|
||||||
|
{
|
||||||
|
var repoRoot = FindRepositoryRoot();
|
||||||
|
var source = File.ReadAllText(Path.Combine(
|
||||||
|
repoRoot,
|
||||||
|
"src",
|
||||||
|
"GmRelay.Shared",
|
||||||
|
"Features",
|
||||||
|
"Sessions",
|
||||||
|
"CreateSession",
|
||||||
|
"Wizard",
|
||||||
|
"WizardDraft.cs"));
|
||||||
|
|
||||||
|
Assert.Contains("public DateTime CreatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public DateTime UpdatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("public DateTime ExpiresAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("public DateTimeOffset CreatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("public DateTimeOffset UpdatedAt", source, StringComparison.Ordinal);
|
||||||
|
Assert.DoesNotContain("public DateTimeOffset ExpiresAt", source, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractMethodBody(string source, string methodName, string containingClass)
|
||||||
|
{
|
||||||
|
var searchFrom = source.IndexOf(methodName, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
// If a containing class is given (non-empty), narrow the search
|
||||||
|
// to the first occurrence AFTER the class declaration. This is
|
||||||
|
// needed when the same method name is used as a call site
|
||||||
|
// elsewhere in the file.
|
||||||
|
if (!string.IsNullOrEmpty(containingClass))
|
||||||
|
{
|
||||||
|
var classIdx = source.IndexOf(containingClass, StringComparison.Ordinal);
|
||||||
|
if (classIdx < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate class {containingClass} in source.");
|
||||||
|
}
|
||||||
|
searchFrom = source.IndexOf(methodName, classIdx, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchFrom < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate {methodName} in source.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept any return type: `public async Task` (no result) or
|
||||||
|
// `public async Task<int>` (with result). Search for the keyword
|
||||||
|
// "Task" in a 60-char window before the method name so we also
|
||||||
|
// pick up `public static async Task<IReadOnlyList<ulong>>`.
|
||||||
|
var windowStart = Math.Max(0, searchFrom - 60);
|
||||||
|
var idx = source.IndexOf("Task", windowStart, StringComparison.Ordinal);
|
||||||
|
if (idx < 0 || idx >= searchFrom)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate {methodName} declaration in source.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var braceStart = source.IndexOf('{', idx);
|
||||||
|
if (braceStart < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Could not locate body opening brace for {methodName}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var depth = 1;
|
||||||
|
var pos = braceStart + 1;
|
||||||
|
while (pos < source.Length && depth > 0)
|
||||||
|
{
|
||||||
|
switch (source[pos])
|
||||||
|
{
|
||||||
|
case '{': depth++; break;
|
||||||
|
case '}': depth--; break;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return source.Substring(braceStart, pos - braceStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FindRepositoryRoot()
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
if (File.Exists(Path.Combine(directory.FullName, "Directory.Build.props")))
|
||||||
|
{
|
||||||
|
return directory.FullName;
|
||||||
|
}
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException("Could not locate repository root.");
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-9
@@ -16,11 +16,11 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
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);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
draft.Step = "Title";
|
draft.Step = "Title";
|
||||||
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
|
draft.UpdatedAt = DateTime.UtcNow.AddSeconds(1);
|
||||||
await sut.UpsertAsync(draft, CancellationToken.None);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, 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);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
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);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, 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);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
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);
|
await sut.UpsertAsync(draft, CancellationToken.None);
|
||||||
|
|
||||||
var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1)
|
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);
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
var sut = new WizardDraftRepository(dataSource);
|
var sut = new WizardDraftRepository(dataSource);
|
||||||
|
|
||||||
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
|
var fresh = NewDraft("Type", DateTime.UtcNow.AddHours(1));
|
||||||
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
|
var stale = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
|
||||||
stale.Id = Guid.NewGuid();
|
stale.Id = Guid.NewGuid();
|
||||||
await sut.UpsertAsync(fresh, CancellationToken.None);
|
await sut.UpsertAsync(fresh, CancellationToken.None);
|
||||||
await sut.UpsertAsync(stale, CancellationToken.None);
|
await sut.UpsertAsync(stale, CancellationToken.None);
|
||||||
@@ -78,7 +78,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
Assert.NotNull(loadedFresh);
|
Assert.NotNull(loadedFresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
|
private static WizardDraft NewDraft(string step, DateTime expiresAt) => new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChatId = "42",
|
ChatId = "42",
|
||||||
@@ -87,8 +87,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
|
|||||||
Platform = "Telegram",
|
Platform = "Telegram",
|
||||||
Step = step,
|
Step = step,
|
||||||
PayloadJson = "{}",
|
PayloadJson = "{}",
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ExpiresAt = expiresAt,
|
ExpiresAt = expiresAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+38
@@ -76,6 +76,40 @@ public sealed class WizardStepRenderTests
|
|||||||
var labels = ButtonLabels(kb);
|
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("Без waitlist", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatStep_HasOnlineAndOfflineButtons()
|
||||||
|
{
|
||||||
|
var (text, kb) = Render(WizardStepNames.Format);
|
||||||
|
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||||
|
var labels = ButtonLabels(kb);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Online", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Offline", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LocationStep_ForOnline_AsksForLink()
|
||||||
|
{
|
||||||
|
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Online });
|
||||||
|
|
||||||
|
Assert.Contains("ссыл", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var labels = ButtonLabels(kb);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LocationStep_ForOffline_AsksForAddress()
|
||||||
|
{
|
||||||
|
var (text, kb) = Render(WizardStepNames.Location, new WizardPayload { Format = WizardSessionFormat.Offline });
|
||||||
|
|
||||||
|
Assert.Contains("адрес", text, StringComparison.OrdinalIgnoreCase);
|
||||||
|
var labels = ButtonLabels(kb);
|
||||||
|
Assert.Contains(labels, l => l.Contains("Назад", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -134,10 +168,14 @@ public sealed class WizardStepRenderTests
|
|||||||
{
|
{
|
||||||
Type = WizardCreationType.Single,
|
Type = WizardCreationType.Single,
|
||||||
Title = "My Game",
|
Title = "My Game",
|
||||||
|
Format = WizardSessionFormat.Offline,
|
||||||
|
LocationAddress = "Москва, ул. Кубиков, 12",
|
||||||
});
|
});
|
||||||
|
|
||||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||||
Assert.Contains("My Game", text);
|
Assert.Contains("My Game", text);
|
||||||
|
Assert.Contains("Offline", text);
|
||||||
|
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||||
var labels = ButtonLabels(kb);
|
var labels = ButtonLabels(kb);
|
||||||
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Создать", StringComparison.Ordinal));
|
||||||
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
Assert.Contains(labels, l => l.Contains("Отмена", StringComparison.Ordinal));
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ internal static class WizardTestFakes
|
|||||||
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
|
||||||
payload ?? new WizardPayload(),
|
payload ?? new WizardPayload(),
|
||||||
WizardPayloadJsonContext.Default.WizardPayload),
|
WizardPayloadJsonContext.Default.WizardPayload),
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTimeOffset.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
|
ExpiresAt = DateTime.UtcNow.AddHours(24),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
using GmRelay.Bot.Tests.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Domain;
|
||||||
|
using GmRelay.Shared.Infrastructure.Scheduling;
|
||||||
|
using GmRelay.Shared.Platform;
|
||||||
|
using Npgsql;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Scheduling;
|
||||||
|
|
||||||
|
[Collection(CreateSessionHandlerPostgresCollection.Name)]
|
||||||
|
public sealed class DbSessionTriggerStoreTests(CreateSessionHandlerPostgresFixture fixture)
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSessionsNeedingJoinLinkAsync_IgnoresConfirmedSessionsWithoutJoinLink()
|
||||||
|
{
|
||||||
|
var connectionString = await fixture.CreateMigratedDatabaseAsync();
|
||||||
|
await using var dataSource = NpgsqlDataSource.Create(connectionString);
|
||||||
|
await using var connection = await dataSource.OpenConnectionAsync();
|
||||||
|
|
||||||
|
var groupId = await InsertTelegramGroupAsync(connection);
|
||||||
|
var dueAt = DateTimeOffset.UtcNow.AddMinutes(4).UtcDateTime;
|
||||||
|
var onlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, "https://vtt.example/game", "Online");
|
||||||
|
var offlineSessionId = await InsertSessionAsync(connection, groupId, dueAt, string.Empty, "Offline");
|
||||||
|
|
||||||
|
var sut = new DbSessionTriggerStore(dataSource, new PlatformSchedulerOptions(PlatformKind.Telegram));
|
||||||
|
|
||||||
|
var result = await sut.GetSessionsNeedingJoinLinkAsync(DateTimeOffset.UtcNow, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Contains(onlineSessionId, result);
|
||||||
|
Assert.DoesNotContain(offlineSessionId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> InsertTelegramGroupAsync(NpgsqlConnection connection)
|
||||||
|
{
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO game_groups (name, platform, external_group_id)
|
||||||
|
VALUES ('Trigger Test Group', 'Telegram', @ExternalGroupId)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("ExternalGroupId", Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
|
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Group insert failed."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid> InsertSessionAsync(
|
||||||
|
NpgsqlConnection connection,
|
||||||
|
Guid groupId,
|
||||||
|
DateTime scheduledAt,
|
||||||
|
string joinLink,
|
||||||
|
string format)
|
||||||
|
{
|
||||||
|
await using var command = new NpgsqlCommand(
|
||||||
|
"""
|
||||||
|
INSERT INTO sessions (group_id, title, join_link, scheduled_at, status, format)
|
||||||
|
VALUES (@GroupId, 'Trigger Test Session', @JoinLink, @ScheduledAt, @Status, @Format)
|
||||||
|
RETURNING id
|
||||||
|
""",
|
||||||
|
connection);
|
||||||
|
command.Parameters.AddWithValue("GroupId", groupId);
|
||||||
|
command.Parameters.AddWithValue("JoinLink", joinLink);
|
||||||
|
command.Parameters.AddWithValue("ScheduledAt", scheduledAt);
|
||||||
|
command.Parameters.AddWithValue("Status", SessionStatus.Confirmed);
|
||||||
|
command.Parameters.AddWithValue("Format", format);
|
||||||
|
|
||||||
|
return (Guid)(await command.ExecuteScalarAsync() ?? throw new InvalidOperationException("Session insert failed."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ using GmRelay.Bot.Infrastructure.Telegram;
|
|||||||
using GmRelay.Shared.Platform;
|
using GmRelay.Shared.Platform;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
namespace GmRelay.Bot.Tests.Infrastructure.Telegram;
|
||||||
|
|
||||||
@@ -36,9 +37,35 @@ public sealed class TelegramPlatformMessengerTests
|
|||||||
Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message);
|
Assert.Contains("Existing schedule message reference must match the schedule group.", exception.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildDirectNotificationText_OneHourReminderWithoutJoinLink_ShouldNotRenderBlankLinkLine()
|
||||||
|
{
|
||||||
|
var notification = new PlatformDirectSessionNotification(
|
||||||
|
PlatformDirectSessionNotificationKind.OneHourReminder,
|
||||||
|
new PlatformUser(PlatformKind.Telegram, "123", "Player", "player"),
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"Offline Game",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
JoinLink: string.Empty);
|
||||||
|
|
||||||
|
var text = InvokeBuildDirectNotificationText(notification);
|
||||||
|
|
||||||
|
Assert.DoesNotContain("🔗", text);
|
||||||
|
}
|
||||||
|
|
||||||
private static TelegramPlatformMessenger CreateMessenger() =>
|
private static TelegramPlatformMessenger CreateMessenger() =>
|
||||||
new(null!, NullLogger<TelegramPlatformMessenger>.Instance);
|
new(null!, NullLogger<TelegramPlatformMessenger>.Instance);
|
||||||
|
|
||||||
|
private static string InvokeBuildDirectNotificationText(PlatformDirectSessionNotification notification)
|
||||||
|
{
|
||||||
|
var method = typeof(TelegramPlatformMessenger).GetMethod(
|
||||||
|
"BuildDirectNotificationText",
|
||||||
|
BindingFlags.NonPublic | BindingFlags.Static);
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
return Assert.IsType<string>(method.Invoke(null, new object[] { notification }));
|
||||||
|
}
|
||||||
|
|
||||||
private static SessionBatchViewModel CreateView() =>
|
private static SessionBatchViewModel CreateView() =>
|
||||||
new("Test batch", []);
|
new("Test batch", []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,56 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
Assert.Equal(2, buttons.Count);
|
Assert.Equal(2, buttons.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldShowOfflineAddress()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
"",
|
||||||
|
"Offline",
|
||||||
|
"Москва, ул. Кубиков, 12"),
|
||||||
|
};
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Offline Test", sessions, participants);
|
||||||
|
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("📍 Адрес:", text);
|
||||||
|
Assert.Contains("Москва, ул. Кубиков, 12", text);
|
||||||
|
Assert.DoesNotContain("Ссылка на игру", text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Render_ShouldShowOnlineLinkWithLinkIcon()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var sessions = new[]
|
||||||
|
{
|
||||||
|
new SessionBatchDto(
|
||||||
|
sessionId,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
SessionStatus.Planned,
|
||||||
|
4,
|
||||||
|
"https://vtt.example/game",
|
||||||
|
"Online",
|
||||||
|
null),
|
||||||
|
};
|
||||||
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
|
var view = SessionBatchViewBuilder.Build("Online Test", sessions, participants);
|
||||||
|
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||||
|
|
||||||
|
Assert.Contains("🔗 Ссылка на игру", text);
|
||||||
|
Assert.Contains("https://vtt.example/game", text);
|
||||||
|
Assert.DoesNotContain("📍 Адрес:", text);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_ShouldEncodeHtmlInJoinLink()
|
public void Render_ShouldEncodeHtmlInJoinLink()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
|
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"));
|
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
|
||||||
Assert.Contains("v3.9.0", navMenu, StringComparison.Ordinal);
|
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -68,4 +76,23 @@ public sealed class CampaignTemplatesNavigationTests
|
|||||||
|
|
||||||
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
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