From 892f39401ce08ddb559202a59457f1f05353ee92 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Tue, 16 Jun 2026 12:22:10 +0300 Subject: [PATCH] feat(e2e): #148 /newsession scenario from creation to publication - Add NewSessionScenario that walks the Telegram wizard: single game, title, skip description/cover, D&D 5e, 4h, datetime, capacity, online format, join link, public visibility, publish, confirm - Add ClickInlineButtonAsync / ClickInlineButtonByTextAsync to TelegramUserClient - Add local WizardCallback/Step constants mirroring GmRelay.Shared wizard wire format - Program.cs now runs full flow: group setup + /newsession + cleanup Co-Authored-By: Claude Opus 4.8 --- tests/e2e/runner/NewSessionScenario.cs | 132 +++++++++++++++++++++++++ tests/e2e/runner/Program.cs | 18 +++- tests/e2e/runner/TelegramUserClient.cs | 50 ++++++++++ tests/e2e/runner/WizardCallback.cs | 36 +++++++ 4 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/runner/NewSessionScenario.cs create mode 100644 tests/e2e/runner/WizardCallback.cs diff --git a/tests/e2e/runner/NewSessionScenario.cs b/tests/e2e/runner/NewSessionScenario.cs new file mode 100644 index 0000000..b612295 --- /dev/null +++ b/tests/e2e/runner/NewSessionScenario.cs @@ -0,0 +1,132 @@ +namespace GmRelay.E2E.Runner; + +/// +/// E2E scenario that walks through the GmRelay /newsession wizard in a Telegram group +/// and verifies that a session is created and visible in the Web dashboard. +/// +public sealed class NewSessionScenario +{ + private readonly TelegramUserClient _client; + private readonly RunnerConfig _config; + + public NewSessionScenario(TelegramUserClient client, RunnerConfig config) + { + _client = client; + _config = config; + } + + public async Task RunAsync( + ChatGroup group, + NewSessionInputs inputs, + CancellationToken cancellationToken = default) + { + await _client.SendCommandAsync(group, "newsession", cancellationToken); + await WaitForStepAsync(group, WizardStep.Type, cancellationToken); + + await _client.ClickInlineButtonAsync( + group, + WizardCallback.Choice(WizardStep.Type, "single"), + cancellationToken: cancellationToken); + await WaitForStepAsync(group, WizardStep.Title, cancellationToken); + + await _client.SendMessageAsync(group, inputs.Title, cancellationToken); + await WaitForStepAsync(group, WizardStep.Description, cancellationToken); + + await _client.SendMessageAsync(group, "-", cancellationToken); + await WaitForStepAsync(group, WizardStep.Cover, cancellationToken); + + await _client.SendMessageAsync(group, "-", cancellationToken); + await WaitForStepAsync(group, WizardStep.System, cancellationToken); + + await _client.ClickInlineButtonAsync( + group, + WizardCallback.Choice(WizardStep.System, "Dnd5e"), + cancellationToken: cancellationToken); + await WaitForStepAsync(group, WizardStep.Duration, cancellationToken); + + await _client.ClickInlineButtonAsync( + group, + WizardCallback.Choice(WizardStep.Duration, "240"), + cancellationToken: cancellationToken); + await WaitForStepAsync(group, WizardStep.DateTime, cancellationToken); + + await _client.SendMessageAsync(group, inputs.ScheduledAtMoscow, cancellationToken); + await WaitForStepAsync(group, WizardStep.Capacity, cancellationToken); + + await _client.SendMessageAsync(group, inputs.MaxPlayers.ToString(System.Globalization.CultureInfo.InvariantCulture), cancellationToken); + await WaitForStepAsync(group, WizardStep.Format, cancellationToken); + + await _client.ClickInlineButtonAsync( + group, + WizardCallback.Choice(WizardStep.Format, "online"), + cancellationToken: cancellationToken); + await WaitForStepAsync(group, WizardStep.Location, cancellationToken); + + await _client.SendMessageAsync(group, inputs.JoinLink, cancellationToken); + await WaitForStepAsync(group, WizardStep.Visibility, cancellationToken); + + await _client.ClickInlineButtonAsync( + group, + WizardCallback.Choice(WizardStep.Visibility, "public"), + cancellationToken: cancellationToken); + await WaitForStepAsync(group, WizardStep.Publish, cancellationToken); + + await _client.ClickInlineButtonAsync( + group, + WizardCallback.Choice(WizardStep.Publish, "yes"), + cancellationToken: cancellationToken); + await WaitForStepAsync(group, WizardStep.Confirm, cancellationToken); + + await _client.ClickInlineButtonAsync( + group, + WizardCallback.Create(), + cancellationToken: cancellationToken); + + var confirmation = await _client.WaitForBotReplyAsync( + group, + containsText: "Создано", + timeout: TimeSpan.FromSeconds(60), + cancellationToken); + + if (confirmation is null) + throw new InvalidOperationException("Wizard did not confirm session creation."); + + Console.WriteLine($"[scenario] session created (msg id={confirmation.id})"); + return new ScenarioResult(group, confirmation.id); + } + + private async Task WaitForStepAsync( + ChatGroup group, + string expectedStep, + CancellationToken cancellationToken = default, + TimeSpan? timeout = null) + { + var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(30)); + while (DateTime.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + var message = await _client.GetLatestBotMessageAsync(group, cancellationToken); + if (message?.reply_markup is TL.ReplyInlineMarkup markup) + { + var dataButtons = markup.rows + .SelectMany(r => r.buttons) + .OfType() + .Select(b => System.Text.Encoding.UTF8.GetString(b.data)) + .ToList(); + + if (dataButtons.Any(d => d.StartsWith($"wizard:{expectedStep}", StringComparison.Ordinal))) + return; + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + + throw new TimeoutException($"Wizard did not reach step '{expectedStep}' in time."); + } +} + +public sealed record NewSessionInputs( + string Title, + string ScheduledAtMoscow, + int MaxPlayers, + string JoinLink); diff --git a/tests/e2e/runner/Program.cs b/tests/e2e/runner/Program.cs index db835d6..b696a7c 100644 --- a/tests/e2e/runner/Program.cs +++ b/tests/e2e/runner/Program.cs @@ -15,13 +15,22 @@ var config = new RunnerConfig using var client = new TelegramUserClient(config); await client.ConnectAsync(); -var scenario = new GroupSetupScenario(client, config); +var setup = new GroupSetupScenario(client, config); ScenarioResult? result = null; try { - result = await scenario.RunAsync(); - Console.WriteLine("Scenario completed successfully."); + result = await setup.RunAsync(); + + var newSession = new NewSessionScenario(client, config); + var inputs = new NewSessionInputs( + Title: "E2E One-Shot Adventure", + ScheduledAtMoscow: DateTime.UtcNow.AddDays(7).AddHours(3).ToString("dd.MM.yyyy HH:mm", System.Globalization.CultureInfo.InvariantCulture), + MaxPlayers: 5, + JoinLink: "https://example.com/join-e2e"); + + await newSession.RunAsync(result.Group, inputs); + Console.WriteLine("Full scenario completed successfully."); } catch (Exception ex) { @@ -32,9 +41,8 @@ finally { if (result is not null) { - await scenario.CleanupAsync(result); + await setup.CleanupAsync(result); } - // Keep the session file written to disk. await Task.Delay(TimeSpan.FromSeconds(2)); } diff --git a/tests/e2e/runner/TelegramUserClient.cs b/tests/e2e/runner/TelegramUserClient.cs index 6ccb8a5..ca68178 100644 --- a/tests/e2e/runner/TelegramUserClient.cs +++ b/tests/e2e/runner/TelegramUserClient.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using System.Text; using TL; using WTelegram; @@ -69,6 +70,12 @@ public sealed class TelegramUserClient : IDisposable .ToList(); } + public async Task GetLatestBotMessageAsync(ChatGroup group, CancellationToken cancellationToken = default) + { + var messages = await GetRecentMessagesAsync(group, limit: 20, cancellationToken); + return messages.FirstOrDefault(m => m.from_id is not PeerUser userPeer || userPeer.user_id != _client.UserId); + } + public async Task WaitForBotReplyAsync( ChatGroup group, string? containsText = null, @@ -104,6 +111,49 @@ public sealed class TelegramUserClient : IDisposable await SendMessageAsync(group, command, cancellationToken); } + public async Task ClickInlineButtonAsync( + ChatGroup group, + string callbackData, + int? messageId = null, + CancellationToken cancellationToken = default) + { + var channel = await ResolveChannelAsync(group.Id, cancellationToken); + var targetMessageId = messageId ?? (await GetLatestBotMessageAsync(group, cancellationToken))?.id + ?? throw new InvalidOperationException("No bot message found to click."); + + await _client.Messages_GetBotCallbackAnswer( + channel, + targetMessageId, + Encoding.UTF8.GetBytes(callbackData)); + } + + public async Task ClickInlineButtonByTextAsync( + ChatGroup group, + string buttonTextContains, + int? messageId = null, + CancellationToken cancellationToken = default) + { + var message = messageId.HasValue + ? (await GetRecentMessagesAsync(group, limit: 20, cancellationToken)).FirstOrDefault(m => m.id == messageId.Value) + : await GetLatestBotMessageAsync(group, cancellationToken); + + if (message is null) + throw new InvalidOperationException("No bot message found to click."); + + if (message.reply_markup is not ReplyInlineMarkup markup) + throw new InvalidOperationException("Latest bot message has no inline keyboard."); + + var button = markup.rows + .SelectMany(r => r.buttons) + .OfType() + .FirstOrDefault(b => b.text.Contains(buttonTextContains, StringComparison.OrdinalIgnoreCase)); + + if (button is null) + throw new InvalidOperationException($"No inline button matching '{buttonTextContains}' found."); + + await ClickInlineButtonAsync(group, Encoding.UTF8.GetString(button.data), message.id, cancellationToken); + } + public void Dispose() { _client.Dispose(); diff --git a/tests/e2e/runner/WizardCallback.cs b/tests/e2e/runner/WizardCallback.cs new file mode 100644 index 0000000..989ea99 --- /dev/null +++ b/tests/e2e/runner/WizardCallback.cs @@ -0,0 +1,36 @@ +namespace GmRelay.E2E.Runner; + +/// +/// Mirrors the wire format used by GmRelay.Shared.Features.Sessions.CreateSession.Wizard.WizardCallbackData. +/// Kept local to avoid a project reference to GmRelay.Shared from the standalone E2E runner. +/// +public static class WizardCallback +{ + public const string Prefix = "wizard"; + + public static string Choice(string step, string choice) => $"{Prefix}:{step}:{choice}"; + + public static string Back() => $"{Prefix}:back"; + + public static string Cancel() => $"{Prefix}:cancel"; + + public static string Create() => $"{Prefix}:create"; +} + +public static class WizardStep +{ + public const string Type = "Type"; + public const string Title = "Title"; + public const string Description = "Description"; + public const string Cover = "Cover"; + public const string System = "System"; + public const string Duration = "Duration"; + public const string DateTime = "DateTime"; + public const string Capacity = "Capacity"; + public const string Format = "Format"; + public const string Location = "Location"; + public const string Visibility = "Visibility"; + public const string PickClub = "PickClub"; + public const string Publish = "Publish"; + public const string Confirm = "Confirm"; +}