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";
+}