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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
namespace GmRelay.E2E.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ScenarioResult> 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<TL.KeyboardButtonCallback>()
|
||||
.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);
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<Message?> 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<Message?> 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<KeyboardButtonCallback>()
|
||||
.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();
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace GmRelay.E2E.Runner;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user