Compare commits

...

17 Commits

Author SHA1 Message Date
Toutsu 1f3fb6e89e fix(bot): install libgssapi-krb5-2 in runtime image
PR Checks / test-and-build (pull_request) Successful in 13m32s
Telegram bot's long-polling hangs after the first GetUpdates request
because libgssapi-krb5.so.2 is missing from the runtime-deps:10.0-noble
final image. .NET runtime attempts dlopen() of libgssapi during the
HTTPS handshake; without the library the HttpClient connection pool
enters an unrecoverable state and TelegramBotService never receives
new updates, even though SessionSchedulerService keeps sending
outgoing messages successfully.

Symptom (Loki, container gmrelaybot-bot-1):
  Telegram bot polling started
  Polling error, retrying in 5s
  Telegram.Bot.Exceptions.RequestException: Bot API Service Failure
  Cannot load library libgssapi_krb5.so.2

After the single Polling error, no Error handling update, no further
Polling error, and getUpdates from outside returns [] forever.

Fix: install libgssapi-krb5-2 alongside wget in the final stage of
src/GmRelay.Bot/Dockerfile. This also future-proofs Npgsql GSS/SSPI
Kerberos authentication for PostgreSQL.

Closes #129.

Bump version 3.9.5 -> 3.9.6
2026-06-09 12:20:32 +03:00
Toutsu e3e6e841b8 Merge pull request #128: fix(bot): keep Capacity and PickClub wizard steps consistent (v3.9.5)
Deploy Telegram Bot / build-and-push (push) Successful in 7m9s
Deploy Telegram Bot / scan-images (push) Successful in 2m23s
Deploy Telegram Bot / deploy (push) Successful in 54s
Closes #127.
2026-06-08 22:51:04 +03:00
Toutsu a0a84965b3 chore: bump version 3.9.4 -> 3.9.5
PR Checks / test-and-build (pull_request) Successful in 10m22s
Bugfix patch release for issue #127. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 22:34:27 +03:00
Toutsu 67e8d5b558 fix(bot): keep capacity and club wizard steps consistent
Fix two wizard FSM bugs reported after v3.9.4:

1. Capacity waitlist buttons could still advance the draft without a
   numeric MaxPlayers value. The final submit validation then rejected
   the draft with 'Не заполнены поля: лимит мест'. Now waitlist:on/off
   stay on Capacity until MaxPlayers is set; users must either enter a
   numeric limit or explicitly choose '♾ Без лимита'.

2. PickClub computed NextAfterVisibility before SetClubId, so the first
   club click left the wizard on PickClub and the second click advanced.
   Now ClubId is saved first and NextAfterVisibility is evaluated after
   that mutation, so a valid club click advances on the first try.

TDD:
- WaitlistChoiceWithoutCapacity_StaysOnCapacityStep covers waitlist:on/off.
- PickClub_ValidGuid_AdvancesToPublishOnFirstClick covers the single-click club path.
- Stale Capacity waitlist callback test updated to the safer no-advance contract.

Closes #127
2026-06-08 22:34:18 +03:00
Toutsu 593f8a62fb Merge pull request #126: test: cleanup follow-up from PR #124 review (v3.9.4)
Deploy Telegram Bot / build-and-push (push) Successful in 6m15s
Deploy Telegram Bot / scan-images (push) Successful in 2m20s
Deploy Telegram Bot / deploy (push) Successful in 46s
Closes #125.
2026-06-08 19:27:58 +03:00
Toutsu aee0ac1e6c chore: bump version 3.9.3 -> 3.9.4
PR Checks / test-and-build (pull_request) Successful in 10m23s
Test-only patch release. Sync the four canonical version sources:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)

The new NavMenu_ShouldExposeCurrentProjectVersion test reads the
version from Directory.Build.props, so this bump does NOT need a
hand-edited test literal — verified locally that the test passes
on 3.9.4 with no manual changes.
2026-06-08 19:11:31 +03:00
Toutsu 68945d931f test: cleanup follow-up from PR #124 review
Two non-blocking suggestions from the PR #124 code review:

1. Symmetric coverage for the Discord PoolSlotCapacity no-limit button.
   The Capacity step already had a customId-shape assertion for the
   '♾ Без лимита' button; PoolSlotCapacity only had a label-presence
   assertion. If ChoiceButtonCustomId's wire format ever diverged between
   the two steps, only Capacity would catch it. Convert the single Fact
   to a Theory with two InlineData rows so a regression in either step
   produces a targeted test failure.

2. Brittle hard-coded version literal in NavMenu_ShouldExposeCurrent
   ProjectVersion. The test had to be hand-edited on every version bump
   (we hit this in PR #124 — bumping 3.9.2 -> 3.9.3 broke CI). Read the
   version from Directory.Build.props via XDocument instead so the test
   only fails when the rendered NavMenu actually disagrees with the
   canonical version, not when someone forgot to update a literal.
   Tolerant of whitespace, comments and attribute order; the <Version>
   element is a plain string body in the MSBuild schema.

No production code changes; production version is bumped in a
follow-up commit.

Closes #125
2026-06-08 19:11:28 +03:00
Toutsu 3db2b703d6 Merge pull request #124: fix(bot,discord): allow 'no player limit' option in /newsession wizard (v3.9.3)
Deploy Telegram Bot / build-and-push (push) Successful in 7m43s
Deploy Telegram Bot / scan-images (push) Successful in 2m28s
Deploy Telegram Bot / deploy (push) Successful in 46s
Closes #123.
2026-06-08 18:47:38 +03:00
Toutsu 3c3ef8db5a test(web): update NavMenu_ShouldExposeCurrentProjectVersion to 3.9.3
PR Checks / test-and-build (pull_request) Successful in 9m57s
The version-bump commit changed the visible version in NavMenu.razor
from 3.9.2 to 3.9.3 but this test hard-coded the old literal. Update
the assertion to match the new release tag.
2026-06-08 18:31:17 +03:00
Toutsu 5c0397a5e6 chore: bump version 3.9.2 -> 3.9.3
PR Checks / test-and-build (pull_request) Failing after 10m16s
Sync the four canonical version sources for the patch release:
- Directory.Build.props
- compose.yaml (bot, discord, web image tags)
- .gitea/workflows/deploy.yml (VERSION env)
- src/GmRelay.Web/Components/Layout/NavMenu.razor (visible nav-version)
2026-06-08 18:17:26 +03:00
Toutsu 15040eb954 fix(bot,discord): allow 'no player limit' option in /newsession wizard
In the session creation wizard (Telegram + Discord), the Capacity step
only exposed waitlist on/off buttons. The 'no waitlist' button silently
advanced to the next step without setting MaxPlayers, so users who tried
to create a session with no player cap were blocked with
'Не заполнены поля: лимит мест'.

The DB contract and CreateSessionCommand already supported null
MaxPlayers (int?, ck_sessions_max_players check in V006), and the web
form already exposes 'Без лимита' as an empty InputNumber — only the
wizard flow was broken.

Changes:
- Add '♾ Без лимита' choice button to Capacity in shared
  WizardStepViewBuilder.BuildCapacity (Telegram) and to
  RenderCapacity / RenderPoolSlotCapacity in DiscordWizardStep (Discord).
- Add 'no_limit' branch to GameCreationWizard.ApplyCapacityChoice that
  sets MaxPlayers to null and advances to Visibility.
- Change GameCreationWizard.SetMaxPlayers signature from int to int? so
  the 'no limit' branch compiles.
- Change CreateSessionCommand builder in both Telegram and Discord
  submitters to take int? maxPlayers and drop the '?? 0' that would
  have turned null into 0 (violating the DB CHECK and the 'no limit'
  contract).
- In Discord BuildConfirmDescription, render '👥 Без лимита, waitlist
  вкл/выкл' when MaxPlayers is null (the previous code silently
  omitted the line).
- Expose BuildCommand as internal in both submitters and add
  InternalsVisibleTo('GmRelay.Bot.Tests') to the DiscordBot assembly
  for unit-test access.

Tests (9 new):
- WizardStepRenderTests.CapacityStep_HasWaitlistButtons — asserts the
  'Без лимита' button is present.
- GameCreationWizardStepTransitionsTests.NoLimitCapacityButton_… —
  asserts the choice advances to Visibility and leaves MaxPlayers null
  in the JSON draft.
- GameCreationWizardStepTransitionsTests.ChoiceCallback_AdvancesToExpectedStep —
  new Theory row for Capacity/no_limit.
- CreateSessionHandlerBuildCommandTests (new) — null/value propagation
  through the Telegram submitter's BuildCommand.
- DiscordWizardStepCapacityRenderTests (new) — 'Без лимита' button is
  rendered for both Capacity and PoolSlotCapacity, with the expected
  custom-id shape.
- DiscordWizardSubmitterBuildCommandTests (new) — null/value
  propagation through the Discord submitter's BuildCommand.

Closes #123
2026-06-08 18:17:01 +03:00
Toutsu 99a58d7835 ci: install Trivy from official Docker image; normalize .gitignore to UTF-8
Deploy Telegram Bot / build-and-push (push) Successful in 52s
Deploy Telegram Bot / scan-images (push) Successful in 3m0s
Deploy Telegram Bot / deploy (push) Successful in 43s
Trivy install keeps failing on the deploy workflow:

  - empty TAG: install.sh falls back to 'latest', but the
    'latest' GitHub release tag is no longer published.
  - pin v0.71.0: pin alone is not durable. The release got
    unpublished and install.sh now dies with
    'unable to find v0.71.0 - use latest'.

Switch to the official aquasec/trivy Docker image:

  - Docker Hub tags are content-addressed and rarely removed,
    so the pin is durable.
  - The image manifest ships linux/amd64, linux/arm64, linux/ppc64le
    and linux/s390x, so the same tag works on the GitHub-hosted
    runner and on the ARM64 Pi runner.
  - We just need docker pull + docker cp /usr/local/bin/trivy.
  - Pinned to 0.70.0 (April 2026) for reasonably current CVE data.

Also normalize .gitignore to plain UTF-8. The working copy had
been re-saved as UTF-16 LE by a Set-Content call without
-Encoding UTF8 (PowerShell 5.1 default on the local Windows box),
so git kept reporting 'Binary files differ' even though the rules
themselves were fine. Re-wrote through the editor to drop the
UTF-16 encoding and added rules for showcase-*.png, *.png.local,
and the test scratch dirs that keep creeping in.
2026-06-08 12:09:19 +03:00
Toutsu f491727cec chore: stop tracking AI scratch dirs and local screenshots
Deploy Telegram Bot / build-and-push (push) Successful in 5m32s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
The previous commit accidentally pulled in .opencode/tmp/, .playwright-mcp/,
.superpowers/, and a handful of local screenshots/logs because
'git add -A' was used during the 3.9.2 fix. None of these affect the
build or the deploy; the deploy workflow was triggered by the version
bump and ran cleanly. Add them to .gitignore and untrack so the next
contributor doesn't commit them again.
2026-06-08 10:49:41 +03:00
Toutsu 2c9016a383 fix(shared,bot,discordbot): make club-picker Dapper calls AOT-safe (v3.9.2)
Deploy Telegram Bot / build-and-push (push) Successful in 7m18s
Deploy Telegram Bot / scan-images (push) Failing after 17s
Deploy Telegram Bot / deploy (push) Has been skipped
The 3.9.1 hotfix only repaired WizardDraftRepository, the most common
Dapper call in the wizard. The same AOT-unsafe CommandDefinition pattern
remained in 4 other places that the user hit immediately after the
deploy: the 'Choose visibility' wizard step triggers GetOwnerClubsAsync
when the user picks 'Публичная в витрине клуба' or 'Только для членов
клуба'. The wizard swallowed PlatformNotSupportedException, the
callback ack replied with '⚠️ Ошибка', and the next step never rendered.
Privacy 'didn't stick' from the user's perspective.

Two changes to fix the Discord side as well:

1. Switched GetOwnerClubsAsync / LoadClubsAsync / LoadManagerUserIdsAsync
   to the direct (sql, params) overload across TelegramWizardMessenger,
   DiscordWizardMessenger, DiscordWizardInteractionModule, and
   DiscordPermissionLookup — same pattern as the 3.9.1 fix.

2. Added Dapper.AOT module attribute ([module: Dapper.DapperAot]) and
   InterceptorsPreviewNamespaces to the DiscordBot project. The
   DiscordBot assembly was previously skipped by the AOT source
   generator, so even the direct-overload fix wouldn't have produced
   interceptors for the Discord-specific Dapper call sites. With this
   addition, the generator emits 3 DiscordBot-specific interceptors
   (DiscordWizardMessenger, DiscordWizardInteractionModule,
   DiscordPermissionLookup) and the AssemblyLoad ships with the right
   GmRelay.DiscordBot.generated.cs.

Also expanded the AOT shape regression tests to cover all 4
CommandDefinition sites + added a 'containingClass' parameter to
ExtractMethodBody to disambiguate the duplicated LoadClubsAsync names
in DiscordWizardInteractionModule.

Bumps: 3.9.1 -> 3.9.2.
2026-06-08 10:48:24 +03:00
Toutsu 065e8011ee ci: pin Trivy v0.71.0 in install step
Deploy Telegram Bot / build-and-push (push) Successful in 42s
Deploy Telegram Bot / scan-images (push) Successful in 2m59s
Deploy Telegram Bot / deploy (push) Successful in 46s
The previous 'curl ... | sh -s -- -b /usr/local/bin' call passed no
positional tag, so the install script fell back to the GitHub 'latest'
tag. aquasecurity/trivy no longer publishes a 'latest' release tag, so
the CI failed at 'Install Trivy' with:
  aquasecurity/trivy crit unable to find '' - use 'latest' or see ...

This blocked the entire 3.9.1 hotfix deploy: build-and-push succeeded
(3 fresh 3.9.1 images pushed to git.codeanddice.ru), but scan-images
never ran and deploy was skipped. Production still runs 3.9.0 with the
broken wizard.

Pass 'v0.71.0' as the positional tag; v0.71.0 has Linux-ARM64 and
Linux-AMD64 builds so both the deploy runner (RPi 5) and pr-checks
runner pick the right tarball.
2026-06-08 10:23:31 +03:00
Toutsu f796b7d1e4 fix(shared): make WizardDraftRepository AOT-safe (v3.9.1 hotfix)
Deploy Telegram Bot / build-and-push (push) Successful in 7m24s
Deploy Telegram Bot / scan-images (push) Failing after 4s
Deploy Telegram Bot / deploy (push) Has been skipped
Production regression in 3.9.0: Telegram bot silently dropped every update.
WizardDraftRepository.GetActiveAsync was called on every Telegram update
(via UpdateRouter -> TryGetWizardContext) and threw
System.PlatformNotSupportedException in NativeAOT, because Dapper.AOT 1.0.48
only generates interceptors for the (sql, object?) extension overloads and
NOT for the (CommandDefinition) overload. The runtime then fell back to
Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit and
fails on AOT. TelegramBotService swallowed the exception, so /newsession
appeared to start but no button press reached the wizard and no session was
created.

Two related changes in WizardDraft:

1. Switched WizardDraftRepository.* from 'new CommandDefinition(sql, params,
   cancellationToken: ct)' to the direct 'connection.Query*(sql, params)'
   overload, matching the working pattern in JoinSessionHandler. Dapper.AOT
   now generates CommandFactory30<WizardDraft> + RowFactory17<WizardDraft> +
   QuerySingleOrDefaultAsync37<WizardDraft> for all four methods.

2. WizardDraft.CreatedAt/UpdatedAt/ExpiresAt are now DateTime (UTC) instead
   of DateTimeOffset. AOT RowFactory calls reader.GetDateTime() directly and
   does not perform DateTime -> DateTimeOffset conversion; the previous type
   raised InvalidCastException on the very first wizard_drafts query.

All 588/590 tests pass (2 pre-existing skipped, +5 new AOT regression tests
in WizardDraftRepositoryAotShapeTests). dotnet format clean.

Bumps: 3.9.0 -> 3.9.1.

Note: GetOwnerClubsAsync (Telegram/Discord), DiscordPermissionLookup, and
DiscordWizardInteractionModule.GetOwnerClubsAsync still use CommandDefinition
and will hit the same Reflection.Emit AOT failure when the user reaches the
PickClub visibility step. Follow-up in 3.9.2.
2026-06-08 10:02:59 +03:00
Toutsu 415c13bf00 Merge pull request 'feat(discord): step-by-step game/pool creation wizard (issue #112)' (#122) from feat/issue-112-wizard-refactor into main
Deploy Telegram Bot / build-and-push (push) Successful in 6m52s
Deploy Telegram Bot / scan-images (push) Successful in 2m32s
Deploy Telegram Bot / deploy (push) Successful in 41s
Reviewed-on: #122
2026-06-06 08:04:16 +03:00
33 changed files with 693 additions and 96 deletions
+22 -2
View File
@@ -6,7 +6,7 @@ on:
- main
env:
VERSION: 3.9.0
VERSION: 3.9.6
jobs:
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
@@ -72,7 +72,27 @@ jobs:
steps:
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale:
# 1. install.sh resolves the positional tag against the
# GitHub releases API; when a release is unpublished or
# yanked, the script fails with
# `unable to find '<tag>' - use 'latest' or see ...`
# even when the release once existed. We hit this with
# v0.71.0.
# 2. Docker Hub tags are content-addressed and rarely
# removed, so a pinned image tag is much more stable.
# 3. The image is multi-arch (linux/amd64, linux/arm64,
# linux/ppc64le, linux/s390x) so the same tag works on
# the GitHub-hosted runner and on the ARM64 Pi runner.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Scan Bot image
run: |
+13 -1
View File
@@ -47,7 +47,19 @@ jobs:
- name: Install Trivy
run: |
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin
# Install Trivy from the official Docker image instead of the
# upstream install.sh. Rationale (see deploy.yml for the long
# version): the GitHub release tag we pinned (v0.71.0) was
# unpublished, and install.sh fails hard on missing tags.
# Docker Hub images are content-addressed and rarely removed,
# and the multi-arch manifest covers linux/amd64 + linux/arm64.
set -euo pipefail
TRIVY_VERSION="0.70.0"
docker pull --quiet "aquasec/trivy:${TRIVY_VERSION}"
docker create --name trivy-tmp "aquasec/trivy:${TRIVY_VERSION}"
docker cp trivy-tmp:/usr/local/bin/trivy /usr/local/bin/trivy
docker rm trivy-tmp >/dev/null
chmod +x /usr/local/bin/trivy
trivy --version
- name: Trivy filesystem security scan
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>3.9.0</Version>
<Version>3.9.6</Version>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
+38 -1
View File
@@ -1,4 +1,41 @@
## 🎯 Minor 3.9.0Discord-визард создания игры/пула (issue #112)
## 🐞 Patch 3.9.2Hotfix: 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) использовали один и тот же движок визарда.
+3 -3
View File
@@ -49,7 +49,7 @@ services:
crond -f
bot:
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-bot:3.9.6
restart: always
depends_on:
db:
@@ -67,7 +67,7 @@ services:
retries: 3
discord:
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-discord-bot:3.9.6
restart: always
depends_on:
db:
@@ -86,7 +86,7 @@ services:
retries: 3
web:
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.0
image: git.codeanddice.ru/toutsu/gmrelay-web:3.9.6
restart: always
depends_on:
db:
+4 -2
View File
@@ -30,8 +30,10 @@ RUN dotnet publish "GmRelay.Bot.csproj" -c Release -a $TARGETARCH -o /app/publis
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble AS final
WORKDIR /app
# Устанавливаем wget для healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends wget \
# Устанавливаем wget для healthcheck и libgssapi-krb5-2 для Npgsql GSS/SSPI
# и HTTPS-handshake Telegram.Bot (без неё long-polling падает на первом запросе).
RUN apt-get update && apt-get install -y --no-install-recommends \
wget libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
# Копируем только AOT-результаты из билда
@@ -66,16 +66,16 @@ public sealed class CreateSessionHandler
OwnerId = ownerId,
Platform = PlatformName,
Step = WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
await _drafts.UpsertAsync(draft, ct);
var (text, actions) = WizardStepViewBuilder.Build(draft, new WizardPayload());
var msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
return draft;
}
@@ -135,7 +135,7 @@ public sealed class CreateSessionHandler
await _drafts.DeleteAsync(draft.Id, ct);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await _messenger.EditDraftMessageAsync(
draft,
@@ -170,7 +170,7 @@ public sealed class CreateSessionHandler
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
p.Single?.MaxPlayers,
isOneShot: true),
};
}
@@ -178,11 +178,11 @@ public sealed class CreateSessionHandler
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand(
internal static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers,
int? maxPlayers,
bool isOneShot)
{
var user = new PlatformUser(
@@ -82,6 +82,14 @@ public sealed class TelegramWizardMessenger(
// and game_groups has no `club_id` FK). The picker therefore returns the
// game_groups the owner manages as a GM (via group_managers), matching
// the WizardClubOption contract (UUID id, name) used downstream.
//
// NativeAOT: Dapper.AOT 1.0.48 only generates interceptors for the
// (sql, object?) extension overload — not the (CommandDefinition) overload.
// The wizard reaches this method on the PickClub visibility step
// (issue #112 follow-up); using CommandDefinition here would fall back
// to Dapper.SqlMapper.CreateParamInfoGenerator, which uses Reflection.Emit
// and throws PlatformNotSupportedException on AOT. Same root cause as
// WizardDraftRepository.GetActiveAsync in v3.9.0, same fix pattern.
const string sql = """
SELECT g.id AS ClubId,
g.name AS Name
@@ -95,10 +103,8 @@ public sealed class TelegramWizardMessenger(
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
var rows = await connection.QueryAsync<WizardClubOption>(
new CommandDefinition(
sql,
new { Platform = "Telegram", ExternalId = ownerId },
cancellationToken: ct));
sql,
new { Platform = "Telegram", ExternalId = ownerId });
return rows.AsList();
}
@@ -31,8 +31,10 @@ internal static class DiscordPermissionLookup
""";
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await connection.QueryAsync<ulong>(
new CommandDefinition(sql, new { GuildId = guildId.ToString() }, cancellationToken: cancellationToken));
sql,
new { GuildId = guildId.ToString() });
return rows.ToList();
}
}
@@ -115,9 +115,9 @@ public sealed class DiscordWizardCommand : ApplicationCommandModule<SlashCommand
Step = NormalizeMode(mode) is { } m && m == WizardCreationType.Pool
? WizardStepNames.Title
: WizardStepNames.Type,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
// If the user passed `mode=pool` we pre-seed the payload so the
// 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 msgId = await _messenger.SendDraftMessageAsync(draft, text, actions, ct);
draft.DraftMessageId = msgId;
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
await Context.Interaction.ModifyResponseAsync(msg =>
@@ -537,11 +537,10 @@ internal static class WizardClubLookup
ORDER BY g.name
""";
await using var conn = await dataSource.OpenConnectionAsync(ct);
// NativeAOT: direct overload — see TelegramWizardMessenger.
var rows = await conn.QueryAsync<WizardClubOption>(
new CommandDefinition(
sql,
new { Platform = "Discord", OwnerId = ownerId },
cancellationToken: ct));
sql,
new { Platform = "Discord", OwnerId = ownerId });
return rows.AsList();
}
}
@@ -164,11 +164,11 @@ public sealed class DiscordWizardMessenger : IWizardMessenger
ORDER BY g.name
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct);
// NativeAOT: direct (sql, params) overload — see
// TelegramWizardMessenger.GetOwnerClubsAsync for why.
var rows = await conn.QueryAsync<WizardClubOption>(
new CommandDefinition(
sql,
new { Platform = "Discord", ExternalId = ownerId },
cancellationToken: ct));
sql,
new { Platform = "Discord", ExternalId = ownerId });
return rows.AsList();
}
@@ -252,11 +252,12 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderCapacity() => new(
"👥 Лимит мест",
"Введите лимит (1..50) и выберите waitlist.",
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
new[]
{
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.Capacity, "waitlist:on", ButtonStyle.Success),
ChoiceBtn("❌ Без waitlist", WizardStepNames.Capacity, "waitlist:off", ButtonStyle.Danger)),
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.Capacity, "no_limit", ButtonStyle.Primary)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
@@ -371,11 +372,12 @@ public static class DiscordWizardStep
private static DiscordWizardRender RenderPoolSlotCapacity() => new(
"👥 Лимит слотов",
"Введите лимит (1..50) и выберите waitlist.",
"Введите лимит (1..50), выберите waitlist или сразу «♾ Без лимита».",
new[]
{
Row(ChoiceBtn("✅ Waitlist вкл", WizardStepNames.PoolSlotCapacity, "waitlist:on", ButtonStyle.Success),
ChoiceBtn("❌ Без waitlist", WizardStepNames.PoolSlotCapacity, "waitlist:off", ButtonStyle.Danger)),
Row(ChoiceBtn("♾ Без лимита", WizardStepNames.PoolSlotCapacity, "no_limit", ButtonStyle.Primary)),
Row(ControlBtn("⬅️ Назад", "back"),
ControlBtn("❌ Отмена", "cancel", ButtonStyle.Danger)),
},
@@ -417,6 +419,10 @@ public static class DiscordWizardStep
{
sb.Append("👥 Мест: ").Append(mp).Append(", waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
}
else if (p.Type == WizardCreationType.Single)
{
sb.Append("👥 Без лимита, waitlist ").Append(p.Waitlist == true ? "вкл" : "выкл").AppendLine();
}
sb.Append("🔒 Видимость: ").AppendLine(RenderVisibilityText(p.Visibility));
return sb.ToString();
}
@@ -97,7 +97,7 @@ public sealed class DiscordWizardSubmitter
_contextStore.Remove(draft.Id);
return;
}
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
// The full exception (with stack trace, Postgres constraint
// name, sometimes partial SQL) is already logged server-side
@@ -134,7 +134,7 @@ public sealed class DiscordWizardSubmitter
draft,
p,
new[] { p.Single?.ScheduledAt ?? default },
p.Single?.MaxPlayers ?? 0,
p.Single?.MaxPlayers,
isOneShot: true),
};
}
@@ -142,11 +142,11 @@ public sealed class DiscordWizardSubmitter
private static int MaxPlayersForPool(WizardPoolInput pool) =>
pool.Slots.Count == 0 ? 0 : pool.Slots.Max(s => s.MaxPlayers);
private static CreateSessionCommand BuildCommand(
internal static CreateSessionCommand BuildCommand(
WizardDraft draft,
WizardPayload p,
IReadOnlyList<DateTimeOffset> scheduledTimes,
int maxPlayers,
int? maxPlayers,
bool isOneShot)
{
var user = new PlatformUser(
@@ -8,6 +8,7 @@
<UserSecretsId>dotnet-GmRelay.DiscordBot-issue-26</UserSecretsId>
<!-- DiscordBot uses vanilla Dapper in its own handlers; DAP005 requires AOT-enabled Dapper -->
<NoWarn>$(NoWarn);DAP005</NoWarn>
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);Dapper.AOT</InterceptorsPreviewNamespaces>
</PropertyGroup>
<ItemGroup>
+2
View File
@@ -27,6 +27,8 @@ using NetCord.Services.ApplicationCommands;
using NetCord.Services.ComponentInteractions;
using Npgsql;
[module: Dapper.DapperAot]
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("GmRelay.Bot.Tests")]
@@ -169,7 +169,7 @@ public sealed class GameCreationWizard
private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct)
{
draft.UpdatedAt = DateTimeOffset.UtcNow;
draft.UpdatedAt = DateTime.UtcNow;
await _drafts.UpsertAsync(draft, ct);
var payload = LoadPayload(draft);
IReadOnlyList<WizardClubOption>? clubs = null;
@@ -298,12 +298,25 @@ public sealed class GameCreationWizard
: (null, "Неверная длительность"),
};
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch
private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice)
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
if (choice is "no_limit")
{
return (WizardStepNames.Visibility, SetMaxPlayers(p, null));
}
if (choice is "waitlist:on" or "waitlist:off" && p.Single?.MaxPlayers is null)
{
return (null, "Сначала введите лимит мест или нажмите «♾ Без лимита»");
}
return choice switch
{
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
_ => (null, "Неизвестный выбор"),
};
}
private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch
{
@@ -315,9 +328,15 @@ public sealed class GameCreationWizard
};
private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice)
=> Guid.TryParse(choice, out var id)
? (NextAfterVisibility(p), SetClubId(p, id))
: (null, "Неверный идентификатор клуба");
{
if (!Guid.TryParse(choice, out var id))
{
return (null, "Неверный идентификатор клуба");
}
var error = SetClubId(p, id);
return (NextAfterVisibility(p), error);
}
private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch
{
@@ -416,7 +435,7 @@ public sealed class GameCreationWizard
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v)
{ p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; }
private static string? SetMaxPlayers(WizardPayload p, int v)
private static string? SetMaxPlayers(WizardPayload p, int? v)
{ p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; }
private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; }
private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; }
@@ -43,9 +43,9 @@ public sealed class WizardDraft
/// </summary>
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
{
// 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)
{
const string sql = """
@@ -29,13 +36,10 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
ORDER BY updated_at DESC
LIMIT 1
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.QuerySingleOrDefaultAsync<WizardDraft>(
new CommandDefinition(
sql,
new { Platform = platform, OwnerId = ownerId },
cancellationToken: ct));
sql,
new { Platform = platform, OwnerId = ownerId });
}
public async Task UpsertAsync(WizardDraft draft, CancellationToken ct)
@@ -52,22 +56,21 @@ public sealed class WizardDraftRepository(NpgsqlDataSource dataSource) : IWizard
updated_at = EXCLUDED.updated_at,
expires_at = EXCLUDED.expires_at;
""";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, draft, cancellationToken: ct));
await connection.ExecuteAsync(sql, draft);
}
public async Task DeleteAsync(Guid id, CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE id = @Id";
await using var connection = await dataSource.OpenConnectionAsync(ct);
await connection.ExecuteAsync(new CommandDefinition(sql, new { Id = id }, cancellationToken: ct));
await connection.ExecuteAsync(sql, new { Id = id });
}
public async Task<int> DeleteExpiredAsync(CancellationToken ct)
{
const string sql = "DELETE FROM wizard_drafts WHERE expires_at <= NOW()";
await using var connection = await dataSource.OpenConnectionAsync(ct);
return await connection.ExecuteAsync(new CommandDefinition(sql, cancellationToken: ct));
return await connection.ExecuteAsync(sql);
}
}
@@ -97,11 +97,12 @@ public static class WizardStepViewBuilder
BackCancel());
private static (string, IReadOnlyList<WizardAction>) BuildCapacity() => (
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist.",
"👥 Введите лимит мест (1..50) одним числом.\nЗатем нажмите кнопку waitlist. Или сразу «♾ Без лимита».",
new List<WizardAction>
{
new("✅ Waitlist вкл", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on"), WizardActionStyle.Success),
new("❌ Без waitlist", WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:off"), WizardActionStyle.Danger),
new("♾ Без лимита", WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit"), WizardActionStyle.Primary),
});
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
@@ -82,7 +82,7 @@
</button>
</form>
<div class="nav-version">v3.9.0</div>
<div class="nav-version">v3.9.6</div>
</div>
</Authorized>
<NotAuthorized>
@@ -0,0 +1,66 @@
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using NetCord.Rest;
using Xunit;
namespace GmRelay.Bot.Tests.Discord.Wizard;
/// <summary>
/// Renderer tests for the Discord wizard's Capacity / PoolSlotCapacity steps.
/// Locks in the presence of the "♾ Без лимита" button so the user can pick
/// a session with no player cap (null in <c>sessions.max_players</c>), the
/// same affordance the Telegram wizard provides.
/// </summary>
public sealed class DiscordWizardStepCapacityRenderTests
{
[Fact]
public void RenderCapacity_ContainsNoLimitButton()
{
var draft = new WizardDraft { Step = WizardStepNames.Capacity };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var labels = ExtractButtonLabels(render);
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
}
[Fact]
public void RenderPoolSlotCapacity_ContainsNoLimitButton()
{
var draft = new WizardDraft { Step = WizardStepNames.PoolSlotCapacity };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var labels = ExtractButtonLabels(render);
Assert.Contains(labels, l => l.Contains("Без лимита", System.StringComparison.Ordinal));
}
[Theory]
[InlineData(WizardStepNames.Capacity, "wizard:btn:choice:Capacity:no_limit")]
[InlineData(WizardStepNames.PoolSlotCapacity, "wizard:btn:choice:PoolSlotCapacity:no_limit")]
public void Render_NoLimitButton_HasChoiceCustomIdForNoLimit(string step, string expectedCustomIdPrefix)
{
var draft = new WizardDraft { Step = step };
var render = DiscordWizardStep.Render(draft, new WizardPayload());
var buttons = ExtractButtons(render);
var noLimit = buttons.SingleOrDefault(b => b.Label?.Contains("Без лимита", System.StringComparison.Ordinal) == true);
Assert.NotNull(noLimit);
Assert.StartsWith(expectedCustomIdPrefix, noLimit!.CustomId);
}
private static System.Collections.Generic.List<string> ExtractButtonLabels(
DiscordWizardStep.DiscordWizardRender render) =>
render.Components
.OfType<ActionRowProperties>()
.SelectMany(r => r.Components)
.OfType<ButtonProperties>()
.Select(b => b.Label ?? string.Empty)
.ToList();
private static System.Collections.Generic.List<ButtonProperties> ExtractButtons(
DiscordWizardStep.DiscordWizardRender render) =>
render.Components
.OfType<ActionRowProperties>()
.SelectMany(r => r.Components)
.OfType<ButtonProperties>()
.ToList();
}
@@ -0,0 +1,85 @@
using GmRelay.DiscordBot.Features.Sessions.Wizard;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Xunit;
namespace GmRelay.Bot.Tests.Discord.Wizard;
/// <summary>
/// Regression coverage for <see cref="DiscordWizardSubmitter"/>'s
/// <c>BuildCommand</c>: when the wizard payload carries no player limit
/// (user picked «♾ Без лимита» on the Capacity step), the resulting
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never
/// <c>0</c>. <c>0</c> would violate the DB CHECK
/// <c>ck_sessions_max_players</c> in V006 and the contract that
/// <c>null</c> means "no limit".
/// </summary>
public sealed class DiscordWizardSubmitterBuildCommandTests
{
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = null,
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Null(cmd.MaxPlayers);
}
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 5,
},
};
var cmd = DiscordWizardSubmitter.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal(5, cmd.MaxPlayers);
}
}
@@ -0,0 +1,85 @@
using GmRelay.Bot.Features.Sessions.CreateSession;
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
using Xunit;
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
/// <summary>
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
/// when the wizard payload carries no player limit (e.g. user picked
/// «♾ Без лимита» on the Capacity step), the resulting
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
/// <c>0</c> would violate the DB CHECK constraint
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
/// <c>NULL</c>, which is the wire-level representation of "no limit".
/// </summary>
public sealed class CreateSessionHandlerBuildCommandTests
{
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = null,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Null(cmd.MaxPlayers);
}
[Fact]
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
{
var draft = new WizardDraft
{
Id = Guid.NewGuid(),
ChatId = "42",
OwnerId = "100",
Step = "confirm",
};
var payload = new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Visibility = WizardVisibility.Public,
Single = new WizardSingleInput
{
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
MaxPlayers = 5,
},
};
var cmd = CreateSessionHandler.BuildCommand(
draft,
payload,
new[] { payload.Single!.ScheduledAt!.Value },
payload.Single.MaxPlayers,
isOneShot: true);
Assert.Equal(5, cmd.MaxPlayers);
}
}
@@ -20,9 +20,8 @@ public sealed class GameCreationWizardStepTransitionsTests
[InlineData(WizardStepNames.System, "Dnd5e", WizardStepNames.Duration)]
// Duration → DateTime (single, no maxPlayers yet)
[InlineData(WizardStepNames.Duration, "240", WizardStepNames.DateTime)]
// Capacity → Visibility
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
// Capacity → Visibility (only explicit no-limit can skip numeric capacity)
[InlineData(WizardStepNames.Capacity, "no_limit", WizardStepNames.Visibility)]
// Visibility → Publish (public, no club)
[InlineData(WizardStepNames.Visibility, "public", WizardStepNames.Publish)]
// Visibility → PickClub
@@ -71,13 +70,37 @@ public sealed class GameCreationWizardStepTransitionsTests
}
[Fact]
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
{
// The wizard's callback parser uses the step encoded in the callback
// (not the draft's current step) to drive transitions. So a stale
// "Capacity" button pressed while the user is on System will in fact
// move the draft forward as if they had pressed it on Capacity. We
// lock that behaviour in.
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("single", out var single));
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
// поэтому null-MaxPlayers просто не пишется. Оба варианта
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
// и уйдут в БД как NULL — то есть «без лимита».
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
{
Assert.True(
maxPlayers.ValueKind == JsonValueKind.Null,
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
}
}
[Fact]
public async Task StaleCapacityWaitlistCallback_WithoutCapacity_StaysOnCurrentStep()
{
// A stale waitlist button from Capacity must not move a draft forward
// unless MaxPlayers is already set. Otherwise users can reach Confirm
// with a missing capacity and get "Не заполнены поля: лимит мест".
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.System);
drafts.Seed(draft);
@@ -85,17 +108,13 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "waitlist:on");
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Visibility, draft.Step);
Assert.Equal(WizardStepNames.System, draft.Step);
}
[Fact]
public async Task PickClub_ValidGuid_ReachesStableStep()
public async Task PickClub_ValidGuid_AdvancesToPublishOnFirstClick()
{
// The wizard has a quirk: NextAfterVisibility is evaluated before
// SetClubId, so a single click leaves the draft still on PickClub.
// We assert that the wizard does NOT throw and the messenger is asked
// to re-render (i.e. the handler ran end-to-end).
var wizard = BuildWizard(out var drafts, out var messenger);
var wizard = BuildWizard(out var drafts, out _);
var clubId = Guid.NewGuid();
var payload = new WizardPayload
{
@@ -111,8 +130,11 @@ public sealed class GameCreationWizardStepTransitionsTests
var data = WizardCallbackData.Choice(WizardStepNames.PickClub, clubId.ToString());
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
// Wizard acknowledged the callback and re-rendered the (still PickClub) step.
Assert.NotEmpty(messenger.Edits);
Assert.Equal(WizardStepNames.Publish, draft.Step);
using var doc = JsonDocument.Parse(draft.PayloadJson);
var root = doc.RootElement;
Assert.True(root.TryGetProperty("clubId", out var clubIdJson));
Assert.Equal(clubId, clubIdJson.GetGuid());
}
[Fact]
@@ -136,6 +136,29 @@ public sealed class GameCreationWizardValidationTests
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("waitlist:on")]
[InlineData("waitlist:off")]
public async Task WaitlistChoiceWithoutCapacity_StaysOnCapacityStep(string choice)
{
var wizard = BuildWizard(out var drafts, out _);
var draft = NewDraft(WizardStepNames.Capacity,
new WizardPayload
{
Type = WizardCreationType.Single,
Title = "T",
System = "Dnd5e",
DurationMinutes = 240,
Single = new WizardSingleInput { ScheduledAt = DateTimeOffset.UtcNow.AddDays(1) },
});
drafts.Seed(draft);
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, choice);
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
Assert.Equal(WizardStepNames.Capacity, draft.Step);
}
[Theory]
[InlineData("0")]
[InlineData("13")]
@@ -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.");
}
}
@@ -16,11 +16,11 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
draft.Step = "Title";
draft.UpdatedAt = DateTimeOffset.UtcNow.AddSeconds(1);
draft.UpdatedAt = DateTime.UtcNow.AddSeconds(1);
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
@@ -35,7 +35,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
var draft = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
await sut.UpsertAsync(draft, CancellationToken.None);
var loaded = await sut.GetActiveAsync(draft.Platform, draft.OwnerId, CancellationToken.None);
@@ -49,7 +49,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var draft = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var draft = NewDraft("Type", DateTime.UtcNow.AddHours(1));
await sut.UpsertAsync(draft, CancellationToken.None);
var otherOwner = (long.Parse(draft.OwnerId, System.Globalization.CultureInfo.InvariantCulture) + 1)
@@ -65,8 +65,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
await using var dataSource = NpgsqlDataSource.Create(connectionString);
var sut = new WizardDraftRepository(dataSource);
var fresh = NewDraft("Type", DateTimeOffset.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTimeOffset.UtcNow.AddMinutes(-1));
var fresh = NewDraft("Type", DateTime.UtcNow.AddHours(1));
var stale = NewDraft("Type", DateTime.UtcNow.AddMinutes(-1));
stale.Id = Guid.NewGuid();
await sut.UpsertAsync(fresh, CancellationToken.None);
await sut.UpsertAsync(stale, CancellationToken.None);
@@ -78,7 +78,7 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
Assert.NotNull(loadedFresh);
}
private static WizardDraft NewDraft(string step, DateTimeOffset expiresAt) => new()
private static WizardDraft NewDraft(string step, DateTime expiresAt) => new()
{
Id = Guid.NewGuid(),
ChatId = "42",
@@ -87,8 +87,8 @@ public sealed class WizardDraftRepositoryTests(WizardDraftRepositoryFixture fixt
Platform = "Telegram",
Step = step,
PayloadJson = "{}",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
};
}
@@ -76,6 +76,7 @@ public sealed class WizardStepRenderTests
var labels = ButtonLabels(kb);
Assert.Contains(labels, l => l.Contains("Waitlist вкл", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без waitlist", StringComparison.Ordinal));
Assert.Contains(labels, l => l.Contains("Без лимита", StringComparison.Ordinal));
}
[Fact]
@@ -40,9 +40,9 @@ internal static class WizardTestFakes
PayloadJson = System.Text.Json.JsonSerializer.Serialize(
payload ?? new WizardPayload(),
WizardPayloadJsonContext.Default.WizardPayload),
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
ExpiresAt = DateTimeOffset.UtcNow.AddHours(24),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddHours(24),
};
/// <summary>
@@ -14,8 +14,16 @@ public sealed class CampaignTemplatesNavigationTests
[Fact]
public async Task NavMenu_ShouldExposeCurrentProjectVersion()
{
// Read the version from Directory.Build.props (the canonical source of
// truth) so the test doesn't need to be hand-edited on every version
// bump. Asserting the rendered NavMenu matches the canonical version
// catches real bugs (e.g. someone bumps Directory.Build.props but
// forgets to update NavMenu.razor) without false alarms from a stale
// hard-coded literal.
var propsPath = FindRepositoryFile("Directory.Build.props");
var version = ReadVersionFromProps(propsPath);
var navMenu = await File.ReadAllTextAsync(FindRepositoryFile("src/GmRelay.Web/Components/Layout/NavMenu.razor"));
Assert.Contains("v3.9.0", navMenu, StringComparison.Ordinal);
Assert.Contains($"v{version}", navMenu, StringComparison.Ordinal);
}
[Fact]
@@ -68,4 +76,23 @@ public sealed class CampaignTemplatesNavigationTests
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
}
/// <summary>
/// Parse the <c>&lt;Version&gt;...&lt;/Version&gt;</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;
}
}