Compare commits
17 Commits
de121d7523
...
v3.11.2
| Author | SHA1 | Date | |
|---|---|---|---|
| a391c51761 | |||
| e15652399b | |||
| 40b13db320 | |||
| e0ee8fc962 | |||
| 6707a2850c | |||
| d137c334d6 | |||
| 27f9ceb038 | |||
| f53c1f6aae | |||
| e59b0a78fd | |||
| b952be23eb | |||
| 4054d49ccb | |||
| d678c59105 | |||
| 20b4240a11 | |||
| e846a75ca1 | |||
| 29e5652477 | |||
| 02fc5bd106 | |||
| 6cd68493f1 |
@@ -70,6 +70,13 @@ jobs:
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.codeanddice.ru
|
||||
username: toutsu
|
||||
password: ${{ secrets.GIT_TOKEN }}
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
# Install Trivy from the official Docker image instead of the
|
||||
@@ -78,7 +85,7 @@ jobs:
|
||||
# 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
|
||||
# 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.
|
||||
@@ -94,9 +101,16 @@ jobs:
|
||||
chmod +x /usr/local/bin/trivy
|
||||
trivy --version
|
||||
|
||||
- name: Pull images for scan
|
||||
run: |
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-bot:${{ env.VERSION }}
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-discord-bot:${{ env.VERSION }}
|
||||
docker pull git.codeanddice.ru/toutsu/gmrelay-web:${{ env.VERSION }}
|
||||
|
||||
- name: Scan Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
@@ -105,6 +119,7 @@ jobs:
|
||||
- name: Scan Discord Bot image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
@@ -113,6 +128,7 @@ jobs:
|
||||
- name: Scan Web image
|
||||
run: |
|
||||
trivy image \
|
||||
--timeout 30m \
|
||||
--severity HIGH,CRITICAL \
|
||||
--exit-code 1 \
|
||||
--format table \
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Trivy filesystem security scan
|
||||
run: |
|
||||
set +e
|
||||
trivy fs --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
|
||||
trivy fs --timeout 30m --scanners vuln,misconfig,secret --exit-code 1 --severity HIGH,CRITICAL . 2>&1 | tee trivy-scan.log
|
||||
trivy_exit="${PIPESTATUS[0]}"
|
||||
if ! grep -Eq "Number of language-specific files[[:space:]]+num=[1-9][0-9]*" trivy-scan.log; then
|
||||
echo "::error::Trivy did not detect any language-specific dependency files."
|
||||
@@ -90,4 +90,11 @@ jobs:
|
||||
# ── Tests ──
|
||||
|
||||
- name: Run tests
|
||||
run: dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj --verbosity normal
|
||||
run: |
|
||||
# Exclude Testcontainers-backed PostgreSQL integration collections from PR CI.
|
||||
# The ARM64 runner is too slow to reliably start Postgres containers and apply
|
||||
# migrations before the default timeouts expire. These tests are still run
|
||||
# locally and can be executed manually with `dotnet test`.
|
||||
dotnet test tests/GmRelay.Bot.Tests/GmRelay.Bot.Tests.csproj \
|
||||
--filter "FullyQualifiedName!~PortfolioMigrationPostgresTests&FullyQualifiedName!~CreateSessionHandlerIntegrationTests&FullyQualifiedName!~WizardDraftRepositoryTests&FullyQualifiedName!~DbSessionTriggerStoreTests&Collection!~CreateSessionHandlerPostgresCollection" \
|
||||
--verbosity normal
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
||||
<!-- Overrides transitive vulnerable MessagePack 2.5.192 pulled by Aspire.Hosting.PostgreSQL.
|
||||
See GHSA-hv8m-jj95-wg3x / CVE-2026-48109. -->
|
||||
<PackageReference Include="MessagePack" Version="2.5.301" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
@@ -83,6 +83,16 @@
|
||||
"System.IO.Hashing": "10.0.3"
|
||||
}
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.5.301, )",
|
||||
"resolved": "2.5.301",
|
||||
"contentHash": "WUnJgmYc06ngIxZxLe9sa0P6rOTyOZIQn8SuDvJSjyMn7e8/AdlNAdt81WPUhWKeQ7hDkgxKU1vTrJqX/4L79A==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "2.5.301",
|
||||
"Microsoft.NET.StringTools": "17.6.3"
|
||||
}
|
||||
},
|
||||
"SecurityCodeScan.VS2019": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.7, )",
|
||||
@@ -248,19 +258,10 @@
|
||||
"YamlDotNet": "16.3.0"
|
||||
}
|
||||
},
|
||||
"MessagePack": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.5.192",
|
||||
"contentHash": "Jtle5MaFeIFkdXtxQeL9Tu2Y3HsAQGoSntOzrn6Br/jrl6c8QmG22GEioT5HBtZJR0zw0s46OnKU8ei2M3QifA==",
|
||||
"dependencies": {
|
||||
"MessagePack.Annotations": "2.5.192",
|
||||
"Microsoft.NET.StringTools": "17.6.3"
|
||||
}
|
||||
},
|
||||
"MessagePack.Annotations": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.5.192",
|
||||
"contentHash": "jaJuwcgovWIZ8Zysdyf3b7b34/BrADw4v82GaEZymUhDd3ScMPrYd/cttekeDteJJPXseJxp04yTIcxiVUjTWg=="
|
||||
"resolved": "2.5.301",
|
||||
"contentHash": "3PyBiSeKTfvtyzUv3+9eXGIw7vBBZ0GAc4k3+RVT0tz2vKv3l0pviiA2b6DrmHyDvj1Au8lSVDDw/wKPMxUQ4A=="
|
||||
},
|
||||
"Microsoft.Extensions.AI.Abstractions": {
|
||||
"type": "Transitive",
|
||||
|
||||
@@ -23,11 +23,18 @@ internal static class SessionListMessageRenderer
|
||||
|
||||
public static IReadOnlyList<PlatformMessageAction> RenderActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
if (sessions.Count == 0 || !sessions.First().CanManage)
|
||||
if (sessions.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return sessions.First().CanManage
|
||||
? RenderManagerActions(sessions)
|
||||
: RenderPlayerActions(sessions);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PlatformMessageAction> RenderManagerActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
var actions = new List<PlatformMessageAction>();
|
||||
|
||||
foreach (var session in sessions)
|
||||
@@ -36,19 +43,19 @@ internal static class SessionListMessageRenderer
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"cancel_session:{session.Id}",
|
||||
$"❌ {dateTitle}",
|
||||
$"❌ Отменить {dateTitle}",
|
||||
$"cancel_session:{session.Id}"));
|
||||
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"reschedule_session:{session.Id}",
|
||||
$"⏰ {dateTitle}",
|
||||
$"⏰ Перенести {dateTitle}",
|
||||
$"reschedule_session:{session.Id}"));
|
||||
|
||||
if (SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, session.PlayerCount, session.WaitlistCount))
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"promote_waitlist:{session.Id}",
|
||||
$"⬆️ Из ожидания {dateTitle}",
|
||||
$"⬆️ С ожидания {dateTitle}",
|
||||
$"promote_waitlist:{session.Id}"));
|
||||
}
|
||||
|
||||
@@ -60,4 +67,31 @@ internal static class SessionListMessageRenderer
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PlatformMessageAction> RenderPlayerActions(IReadOnlyList<SessionListItemDto> sessions)
|
||||
{
|
||||
var actions = new List<PlatformMessageAction>();
|
||||
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var dateTitle = session.ScheduledAt.FormatMoscowShort();
|
||||
|
||||
if (session.IsUserActive || session.IsUserWaitlisted)
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"leave_session:{session.Id}",
|
||||
session.IsUserWaitlisted ? $"✖️ Выйти из ожидания {dateTitle}" : $"✖️ Выйти {dateTitle}",
|
||||
$"leave_session:{session.Id}"));
|
||||
}
|
||||
else
|
||||
{
|
||||
actions.Add(new PlatformMessageAction(
|
||||
$"join_session:{session.Id}",
|
||||
$"✅ Записаться {dateTitle}",
|
||||
$"join_session:{session.Id}"));
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
using Telegram.Bot;
|
||||
using Telegram.Bot.Types;
|
||||
|
||||
namespace GmRelay.Bot.Infrastructure.Telegram;
|
||||
|
||||
/// <summary>
|
||||
/// Registers the bot's command list with Telegram so users see the
|
||||
/// command menu when they type "/" in a chat.
|
||||
/// </summary>
|
||||
public sealed class TelegramCommandsSetupService(
|
||||
ITelegramBotClient bot,
|
||||
ILogger<TelegramCommandsSetupService> logger) : IHostedService
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var commands = new[]
|
||||
{
|
||||
new BotCommand { Command = "start", Description = "Начать работу с ботом" },
|
||||
new BotCommand { Command = "newsession", Description = "Создать новую игровую сессию" },
|
||||
new BotCommand { Command = "listsessions", Description = "Список предстоящих сессий" },
|
||||
new BotCommand { Command = "exportcalendar", Description = "Экспортировать расписание в ICS" },
|
||||
new BotCommand { Command = "help", Description = "Справка по командам" }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await bot.SetMyCommands(
|
||||
commands,
|
||||
scope: new BotCommandScopeAllPrivateChats(),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
await bot.SetMyCommands(
|
||||
commands,
|
||||
scope: new BotCommandScopeAllGroupChats(),
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
logger.LogInformation("Telegram command menu registered for private chats and groups.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Failed to register Telegram command menu.");
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -366,6 +366,13 @@ public sealed class UpdateRouter(
|
||||
text: """
|
||||
GM-Relay — бот для управления игровыми сессиями.
|
||||
|
||||
/start — начать работу с ботом
|
||||
/newsession — создать новую игровую сессию
|
||||
/listsessions — список предстоящих сессий
|
||||
/exportcalendar — экспортировать расписание в ICS
|
||||
/help — эта справка
|
||||
|
||||
Пример создания сессии:
|
||||
/newsession
|
||||
Название: My Game
|
||||
Время: 15.05.2026 19:30
|
||||
@@ -377,10 +384,8 @@ public sealed class UpdateRouter(
|
||||
Игр: 4
|
||||
Интервал: 7
|
||||
|
||||
/listsessions — список предстоящих сессий
|
||||
Для owner/co-GM /listsessions показывает кнопки отмены, переноса, удаления и повышения из листа ожидания.
|
||||
Игроки могут записаться кнопкой «На дату» и сняться кнопкой «Выйти».
|
||||
/help — эта справка
|
||||
""",
|
||||
cancellationToken: ct);
|
||||
break;
|
||||
|
||||
@@ -98,6 +98,7 @@ builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||
// ── Telegram infrastructure ──────────────────────────────────────────
|
||||
builder.Services.AddSingleton<UpdateRouter>();
|
||||
builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredService<UpdateRouter>());
|
||||
builder.Services.AddHostedService<TelegramCommandsSetupService>();
|
||||
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||
builder.Services.AddHostedService<TelegramBotService>();
|
||||
|
||||
|
||||
@@ -5,7 +5,17 @@ using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||
public sealed record SessionListItemDto(
|
||||
Guid Id,
|
||||
string Title,
|
||||
DateTime ScheduledAt,
|
||||
string Status,
|
||||
int? MaxPlayers,
|
||||
int PlayerCount,
|
||||
int WaitlistCount,
|
||||
bool CanManage,
|
||||
bool IsUserActive,
|
||||
bool IsUserWaitlisted);
|
||||
|
||||
public sealed record SessionListResult(
|
||||
IReadOnlyList<SessionListItemDto> Sessions,
|
||||
@@ -29,7 +39,27 @@ public sealed class ListSessionsHandler(
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.platform = @Platform
|
||||
AND manager_player.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
) AS CanManage,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM session_participants user_sp
|
||||
JOIN players user_p ON user_p.id = user_sp.player_id
|
||||
WHERE user_sp.session_id = s.id
|
||||
AND user_sp.is_gm = false
|
||||
AND user_sp.registration_status = @Active
|
||||
AND user_p.platform = @Platform
|
||||
AND user_p.external_user_id = @ExternalUserId
|
||||
) AS IsUserActive,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM session_participants user_sp
|
||||
JOIN players user_p ON user_p.id = user_sp.player_id
|
||||
WHERE user_sp.session_id = s.id
|
||||
AND user_sp.is_gm = false
|
||||
AND user_sp.registration_status = @Waitlisted
|
||||
AND user_p.platform = @Platform
|
||||
AND user_p.external_user_id = @ExternalUserId
|
||||
) AS IsUserWaitlisted
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ public sealed class CreateSessionHandlerPostgresCollection : ICollectionFixture<
|
||||
|
||||
public sealed class CreateSessionHandlerPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ public sealed class WizardDraftRepositoryCollection : ICollectionFixture<WizardD
|
||||
|
||||
public sealed class WizardDraftRepositoryFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
+74
-5
@@ -20,7 +20,9 @@ public sealed class SessionListMessageRendererTests
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
true)
|
||||
true,
|
||||
false,
|
||||
false)
|
||||
};
|
||||
|
||||
var text = SessionListMessageRenderer.RenderText(sessions);
|
||||
@@ -32,25 +34,92 @@ public sealed class SessionListMessageRendererTests
|
||||
Assert.Contains(actions, a => a.Payload == $"reschedule_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Payload == $"promote_waitlist:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Payload == $"delete_session:{sessionId}");
|
||||
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
Assert.Contains(actions, a => a.Label == $"❌ Отменить {shortDate}");
|
||||
Assert.Contains(actions, a => a.Label == $"⏰ Перенести {shortDate}");
|
||||
Assert.Contains(actions, a => a.Label == $"⬆️ С ожидания {shortDate}");
|
||||
Assert.Contains(actions, a => a.Label == $"🗑 Удалить {shortDate}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldHideManagerActions_WhenUserCannotManage()
|
||||
public void Render_ShouldIncludeJoinAction_WhenPlayerIsNotRegistered()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionListItemDto(
|
||||
Guid.NewGuid(),
|
||||
sessionId,
|
||||
"Ravenloft",
|
||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
false)
|
||||
};
|
||||
|
||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Contains(actions, a => a.Payload == $"join_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Label == $"✅ Записаться {shortDate}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldIncludeLeaveAction_WhenPlayerIsActive()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionListItemDto(
|
||||
sessionId,
|
||||
"Ravenloft",
|
||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
3,
|
||||
0,
|
||||
false,
|
||||
true,
|
||||
false)
|
||||
};
|
||||
|
||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Label == $"✖️ Выйти {shortDate}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldIncludeLeaveWaitlistAction_WhenPlayerIsWaitlisted()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionListItemDto(
|
||||
sessionId,
|
||||
"Ravenloft",
|
||||
new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
3,
|
||||
1,
|
||||
false)
|
||||
false,
|
||||
false,
|
||||
true)
|
||||
};
|
||||
|
||||
var actions = SessionListMessageRenderer.RenderActions(sessions);
|
||||
Assert.Empty(actions);
|
||||
var shortDate = new DateTime(2026, 5, 7, 16, 30, 0, DateTimeKind.Utc).FormatMoscowShort();
|
||||
|
||||
Assert.Single(actions);
|
||||
Assert.Contains(actions, a => a.Payload == $"leave_session:{sessionId}");
|
||||
Assert.Contains(actions, a => a.Label == $"✖️ Выйти из ожидания {shortDate}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public sealed class PortfolioMigrationPostgresCollection : ICollectionFixture<Po
|
||||
|
||||
public sealed class PortfolioMigrationPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(2);
|
||||
private static readonly TimeSpan ContainerTimeout = TimeSpan.FromMinutes(5);
|
||||
private readonly PostgreSqlContainer container = new PostgreSqlBuilder("postgres:17-alpine").Build();
|
||||
|
||||
public Task InitializeAsync()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using GmRelay.Web.Services;
|
||||
|
||||
namespace GmRelay.Bot.Tests.Web.Rendering;
|
||||
|
||||
public sealed class WebTelegramSessionBatchRendererTests
|
||||
{
|
||||
[Fact]
|
||||
public void Render_ShouldShowStructuredGameCard()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[]
|
||||
{
|
||||
new SessionBatchDto(
|
||||
sessionId,
|
||||
new DateTime(2026, 6, 13, 16, 0, 0, DateTimeKind.Utc),
|
||||
SessionStatus.Planned,
|
||||
4,
|
||||
"https://vtt.example/game",
|
||||
"Hybrid",
|
||||
"Moscow, Kubik Bar",
|
||||
"Mystery one-shot in Bamberg.",
|
||||
"D\u0026D 5e",
|
||||
240,
|
||||
true)
|
||||
};
|
||||
var participants = new[]
|
||||
{
|
||||
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||
new ParticipantBatchDto(sessionId, "Bob", null, ParticipantRegistrationStatus.Waitlisted)
|
||||
};
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Structured Test", sessions, participants);
|
||||
var (text, markup) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("🏷", text);
|
||||
Assert.Contains("Система:", text);
|
||||
Assert.Contains("D\u0026amp;D 5e", text);
|
||||
Assert.Contains("Формат:", text);
|
||||
Assert.Contains("Hybrid", text);
|
||||
Assert.Contains("Тип:", text);
|
||||
Assert.Contains("One-shot", text);
|
||||
Assert.Contains("⏱", text);
|
||||
Assert.Contains("Длительность:", text);
|
||||
Assert.Contains("4 ч", text);
|
||||
Assert.Contains("📝", text);
|
||||
Assert.Contains("Описание:", text);
|
||||
Assert.Contains("Mystery one-shot in Bamberg.", text);
|
||||
Assert.Contains("🔗", text);
|
||||
Assert.Contains("Ссылка:", text);
|
||||
Assert.Contains("📍", text);
|
||||
Assert.Contains("Адрес:", text);
|
||||
Assert.Contains("@alice", text);
|
||||
Assert.Contains("Bob", text);
|
||||
Assert.Contains("Лист ожидания", text);
|
||||
|
||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||
Assert.Equal(2, buttons.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ShouldHandleMissingOptionalFields()
|
||||
{
|
||||
var sessionId = Guid.NewGuid();
|
||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "") };
|
||||
var participants = Array.Empty<ParticipantBatchDto>();
|
||||
|
||||
var view = SessionBatchViewBuilder.Build("Minimal", sessions, participants);
|
||||
var (text, _) = TelegramSessionBatchRenderer.Render(view);
|
||||
|
||||
Assert.Contains("📅", text);
|
||||
Assert.Contains("👥", text);
|
||||
Assert.DoesNotContain("Система:", text);
|
||||
Assert.DoesNotContain("Формат:", text);
|
||||
Assert.DoesNotContain("Длительность:", text);
|
||||
Assert.DoesNotContain("Описание:", text);
|
||||
Assert.DoesNotContain("Ссылка:", text);
|
||||
Assert.DoesNotContain("Адрес:", text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user