Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.9.4
|
||||||
|
|
||||||
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.9.4</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+38
-1
@@ -1,4 +1,41 @@
|
|||||||
## 🎯 Minor 3.9.0 — Discord-визард создания игры/пула (issue #112)
|
## 🐞 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.9.4
|
||||||
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.9.4
|
||||||
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.9.4
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -66,16 +66,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;
|
||||||
}
|
}
|
||||||
@@ -135,7 +135,7 @@ 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,
|
||||||
@@ -170,7 +170,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 +178,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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")]
|
||||||
@@ -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;
|
||||||
@@ -302,6 +302,7 @@ public sealed class GameCreationWizard
|
|||||||
{
|
{
|
||||||
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
"waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)),
|
||||||
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
"waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)),
|
||||||
|
"no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)),
|
||||||
_ => (null, "Неизвестный выбор"),
|
_ => (null, "Неизвестный выбор"),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -416,7 +417,7 @@ public sealed class GameCreationWizard
|
|||||||
private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; }
|
private static string? 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; }
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,11 +97,12 @@ 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>) BuildVisibility() => (
|
private static (string, IReadOnlyList<WizardAction>) BuildVisibility() => (
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v3.9.0</div>
|
<div class="nav-version">v3.9.4</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
using GmRelay.Bot.Features.Sessions.CreateSession;
|
||||||
|
using GmRelay.Shared.Features.Sessions.CreateSession.Wizard;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Features.Sessions.CreateSession.Wizard;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Regression coverage for <see cref="CreateSessionHandler.BuildCommand"/>:
|
||||||
|
/// when the wizard payload carries no player limit (e.g. user picked
|
||||||
|
/// «♾ Без лимита» on the Capacity step), the resulting
|
||||||
|
/// <c>CreateSessionCommand.MaxPlayers</c> must be <c>null</c> — never <c>0</c>.
|
||||||
|
/// <c>0</c> would violate the DB CHECK constraint
|
||||||
|
/// (<c>ck_sessions_max_players</c> in V006) by inserting <c>0</c> instead of
|
||||||
|
/// <c>NULL</c>, which is the wire-level representation of "no limit".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class CreateSessionHandlerBuildCommandTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsNull_PropagatesNull()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Null(cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildCommand_WhenSingleMaxPlayersIsSet_PropagatesValue()
|
||||||
|
{
|
||||||
|
var draft = new WizardDraft
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ChatId = "42",
|
||||||
|
OwnerId = "100",
|
||||||
|
Step = "confirm",
|
||||||
|
};
|
||||||
|
var payload = new WizardPayload
|
||||||
|
{
|
||||||
|
Type = WizardCreationType.Single,
|
||||||
|
Title = "T",
|
||||||
|
System = "Dnd5e",
|
||||||
|
DurationMinutes = 240,
|
||||||
|
Visibility = WizardVisibility.Public,
|
||||||
|
Single = new WizardSingleInput
|
||||||
|
{
|
||||||
|
ScheduledAt = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
MaxPlayers = 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var cmd = CreateSessionHandler.BuildCommand(
|
||||||
|
draft,
|
||||||
|
payload,
|
||||||
|
new[] { payload.Single!.ScheduledAt!.Value },
|
||||||
|
payload.Single.MaxPlayers,
|
||||||
|
isOneShot: true);
|
||||||
|
|
||||||
|
Assert.Equal(5, cmd.MaxPlayers);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
@@ -23,6 +23,7 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
// Capacity → Visibility
|
// Capacity → Visibility
|
||||||
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
|
[InlineData(WizardStepNames.Capacity, "waitlist:on", WizardStepNames.Visibility)]
|
||||||
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
[InlineData(WizardStepNames.Capacity, "waitlist:off", WizardStepNames.Visibility)]
|
||||||
|
[InlineData(WizardStepNames.Capacity, "no_limit", 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
|
||||||
@@ -70,6 +71,32 @@ public sealed class GameCreationWizardStepTransitionsTests
|
|||||||
Assert.Equal(240, dur.GetInt32());
|
Assert.Equal(240, dur.GetInt32());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoLimitCapacityButton_AdvancesToVisibility_AndLeavesMaxPlayersNull()
|
||||||
|
{
|
||||||
|
var wizard = BuildWizard(out var drafts, out _);
|
||||||
|
var draft = NewDraft(WizardStepNames.Capacity, PayloadForStep(WizardStepNames.Capacity));
|
||||||
|
drafts.Seed(draft);
|
||||||
|
|
||||||
|
var data = WizardCallbackData.Choice(WizardStepNames.Capacity, "no_limit");
|
||||||
|
await wizard.HandleInteractionAsync(CallbackInteraction(data, ownerId: draft.OwnerId), draft, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(WizardStepNames.Visibility, draft.Step);
|
||||||
|
using var doc = JsonDocument.Parse(draft.PayloadJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
Assert.True(root.TryGetProperty("single", out var single));
|
||||||
|
// WizardPayloadJsonContext имеет DefaultIgnoreCondition=WhenWritingNull,
|
||||||
|
// поэтому null-MaxPlayers просто не пишется. Оба варианта
|
||||||
|
// (отсутствует / JsonValueKind.Null) десериализуются обратно в null
|
||||||
|
// и уйдут в БД как NULL — то есть «без лимита».
|
||||||
|
if (single.TryGetProperty("maxPlayers", out var maxPlayers))
|
||||||
|
{
|
||||||
|
Assert.True(
|
||||||
|
maxPlayers.ValueKind == JsonValueKind.Null,
|
||||||
|
$"expected maxPlayers to be null (no limit), got {maxPlayers.ValueKind}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
|
public async Task ChoiceCallback_FromMismatchedStep_AdvancesBasedOnCallbackStep()
|
||||||
{
|
{
|
||||||
|
|||||||
+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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -76,6 +76,7 @@ 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]
|
[Fact]
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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