feat(e2e): #146 MTProto Telegram user client runner
- Add standalone C# console runner tests/e2e/runner/ using WTelegramClient - Provide TelegramUserClient wrapper: login, create supergroup, invite bot, send messages/commands, read recent messages, wait for bot reply - Add .env.example and runner .gitignore to keep secrets/session files out of git - Update E2E README with runner instructions and status table - Runner project intentionally excluded from GM-Relay.slnx to avoid CI/AOT impact Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+24
-11
@@ -1,18 +1,31 @@
|
||||
# Python cache and virtual environments
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.env
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
.venv/
|
||||
env/
|
||||
venv/
|
||||
|
||||
# Playwright artifacts
|
||||
screenshots/
|
||||
test-results/
|
||||
playwright-report/
|
||||
# Secrets
|
||||
.env
|
||||
*.env
|
||||
|
||||
# E2E runtime state
|
||||
# Telegram sessions
|
||||
*.session
|
||||
*.session-journal
|
||||
session-*.json
|
||||
|
||||
# Playwright artifacts
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# .NET build artifacts
|
||||
bin/
|
||||
obj/
|
||||
packages.lock.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
+51
-6
@@ -10,8 +10,8 @@ Tracked as a Gitea milestone: [E2E Automation](https://git.codeanddice.ru/toutsu
|
||||
| Issue | Title | Status |
|
||||
|-------|-------|--------|
|
||||
| #144 | initData / Login Widget helper for mock Telegram auth | ✅ Done |
|
||||
| #145 | Playwright tests for Blazor dashboard with mocked Telegram auth | 🚧 In progress |
|
||||
| #146 | Telegram user client (MTProto) | ⏳ Planned |
|
||||
| #145 | Playwright tests for Blazor dashboard with mocked Telegram auth | ✅ Done |
|
||||
| #146 | Telegram user client (MTProto) | 🚧 In progress |
|
||||
| #147 | Automate group creation and bot invitation | ⏳ Planned |
|
||||
| #148 | Scenario: /newsession from creation to publication | ⏳ Planned |
|
||||
| #149 | Join/leave, waitlist, reschedule and notification scenarios | ⏳ Planned |
|
||||
@@ -29,13 +29,23 @@ tests/e2e/
|
||||
│ ├── telegram_init_data.py # Build valid Telegram auth payloads
|
||||
│ ├── test_telegram_init_data.py # Self-contained sanity tests for the helper
|
||||
│ └── __init__.py
|
||||
└── dashboard/
|
||||
├── test_dashboard_auth_and_sessions.py # Playwright tests for the Blazor dashboard
|
||||
└── __init__.py
|
||||
├── dashboard/
|
||||
│ ├── test_dashboard_auth_and_sessions.py # Playwright tests for the Blazor dashboard
|
||||
│ └── __init__.py
|
||||
└── runner/
|
||||
├── GmRelay.E2E.Runner.csproj # C# console runner using WTelegramClient (MTProto)
|
||||
├── Program.cs # Entry point for quick manual checks
|
||||
├── TelegramUserClient.cs # Reusable MTProto user client wrapper
|
||||
├── RunnerConfig.cs # Configuration model
|
||||
├── .env.example # Required environment variables
|
||||
├── .gitignore # Ignore .env and session files
|
||||
└── packages.lock.json # Restored lock file for the runner project
|
||||
```
|
||||
|
||||
## Install dependencies
|
||||
|
||||
### Python (dashboard tests)
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
@@ -43,6 +53,12 @@ pip install -r tests/e2e/requirements.txt
|
||||
playwright install chromium
|
||||
```
|
||||
|
||||
### C# runner (MTProto)
|
||||
|
||||
```bash
|
||||
dotnet restore tests/e2e/runner/GmRelay.E2E.Runner.csproj
|
||||
```
|
||||
|
||||
## Run helper tests
|
||||
|
||||
```bash
|
||||
@@ -67,6 +83,26 @@ python tests/e2e/helpers/test_telegram_init_data.py
|
||||
python tests/e2e/dashboard/test_dashboard_auth_and_sessions.py
|
||||
```
|
||||
|
||||
## Run the MTProto user client runner
|
||||
|
||||
The runner logs in to a real Telegram user account, creates a supergroup, and invites the test bot.
|
||||
|
||||
1. Copy the example environment file and fill in real values:
|
||||
```bash
|
||||
cp tests/e2e/runner/.env.example tests/e2e/runner/.env
|
||||
```
|
||||
2. Edit `tests/e2e/runner/.env` with your Telegram `api_id`, `api_hash`, `phone_number`, the bot username/token, and Web URL.
|
||||
3. Run:
|
||||
```bash
|
||||
dotnet run --project tests/e2e/runner/GmRelay.E2E.Runner.csproj
|
||||
```
|
||||
|
||||
**Security notes:**
|
||||
- Never commit `.env` or `*.session` files.
|
||||
- Use a dedicated test Telegram account, never your personal or production account.
|
||||
- The first run will prompt for the Telegram verification code (sent to the phone number).
|
||||
- Subsequent runs reuse the persisted `.session` file.
|
||||
|
||||
## What the dashboard tests cover
|
||||
|
||||
- `test_dashboard_authenticates_and_shows_groups`
|
||||
@@ -74,8 +110,17 @@ python tests/e2e/helpers/test_telegram_init_data.py
|
||||
- `test_dashboard_session_edit_flow`
|
||||
Seeds a player, group, and session directly in PostgreSQL, opens the group details page, clicks through to the session editor, changes the title, and asserts the updated title appears on the page.
|
||||
|
||||
## What the MTProto runner currently covers
|
||||
|
||||
- Login as a Telegram user.
|
||||
- Create a supergroup (`Channels_CreateChannel` with `megagroup: true`).
|
||||
- Resolve a bot by username and invite it to the group.
|
||||
- Send messages/commands and read recent messages.
|
||||
- Wait for a bot reply.
|
||||
|
||||
## Notes
|
||||
|
||||
- Authentication is mocked using `helpers/telegram_init_data.py`, which mirrors `GmRelay.Shared.Telegram.TelegramAuthPayloadBuilder`.
|
||||
- The Web instance validates HMAC-SHA256 with the same bot token, so the test payload is indistinguishable from a real Telegram Mini App payload.
|
||||
- For headful debugging, change `headless=True` to `headless=False` in the test file.
|
||||
- The runner project is intentionally **not** included in `GM-Relay.slnx` so it does not participate in CI builds or Native AOT trimming.
|
||||
- For headful debugging, change `headless=True` to `headless=False` in the dashboard test file.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Configuration for the GmRelay E2E MTProto runner.
|
||||
# Copy this file to .env and fill in real values.
|
||||
# NEVER commit .env or *.session files to git.
|
||||
|
||||
# Telegram user account credentials (MTProto)
|
||||
api_id=12345678
|
||||
api_hash=abcdef0123456789abcdef0123456789
|
||||
phone_number=+1234567890
|
||||
|
||||
# Bot under test
|
||||
TELEGRAM_BOT_USERNAME=gmrelay_test_bot
|
||||
TELEGRAM_BOT_TOKEN=1234567890:ABCDEF...token
|
||||
|
||||
# Web dashboard under test
|
||||
GMRELAY_E2E_BASE_URL=http://localhost:8080
|
||||
GMRELAY_E2E_TELEGRAM_ID=9000000001
|
||||
|
||||
# PostgreSQL connection string (optional, used for seeding/cleanup)
|
||||
GMRELAY_E2E_DATABASE_URL=Host=localhost;Database=gmrelay;Username=postgres;Password=postgres
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
*.session
|
||||
bin/
|
||||
obj/
|
||||
packages.lock.json
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="WTelegramClient" Version="4.3.5" />
|
||||
<PackageReference Include="dotenv.net" Version="3.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using dotenv.net;
|
||||
using GmRelay.E2E.Runner;
|
||||
|
||||
DotEnv.Load(new DotEnvOptions(envFilePaths: [".env"], ignoreExceptions: false));
|
||||
|
||||
var config = new RunnerConfig
|
||||
{
|
||||
ApiId = int.Parse(Environment.GetEnvironmentVariable("api_id")!),
|
||||
ApiHash = Environment.GetEnvironmentVariable("api_hash")!,
|
||||
PhoneNumber = Environment.GetEnvironmentVariable("phone_number")!,
|
||||
BotUsername = Environment.GetEnvironmentVariable("TELEGRAM_BOT_USERNAME")!,
|
||||
BotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN")!,
|
||||
};
|
||||
|
||||
using var runner = new TelegramUserClient(config);
|
||||
await runner.ConnectAsync();
|
||||
|
||||
Console.WriteLine("Connected as Telegram user. Creating test group...");
|
||||
var group = await runner.CreateGroupAsync("GmRelay E2E Test Group");
|
||||
Console.WriteLine($"Created group id={group.Id} title='{group.Title}'");
|
||||
|
||||
await runner.InviteBotToGroupAsync(group, config.BotUsername);
|
||||
Console.WriteLine($"Invited @{config.BotUsername} to the group");
|
||||
|
||||
// Keep the process alive briefly so the session is persisted.
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
Console.WriteLine("Done.");
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace GmRelay.E2E.Runner;
|
||||
|
||||
public sealed class RunnerConfig
|
||||
{
|
||||
public required int ApiId { get; init; }
|
||||
public required string ApiHash { get; init; }
|
||||
public required string PhoneNumber { get; init; }
|
||||
public required string BotUsername { get; init; }
|
||||
public required string BotToken { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using TL;
|
||||
using WTelegram;
|
||||
|
||||
namespace GmRelay.E2E.Runner;
|
||||
|
||||
public sealed class TelegramUserClient : IDisposable
|
||||
{
|
||||
private readonly Client _client;
|
||||
private readonly RunnerConfig _config;
|
||||
|
||||
public TelegramUserClient(RunnerConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_client = new Client(_ => Environment.GetEnvironmentVariable(_));
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var me = await _client.LoginUserIfNeeded();
|
||||
Console.WriteLine($"Logged in as {me.first_name} {me.last_name} (id={me.id})");
|
||||
}
|
||||
|
||||
public async Task<ChatGroup> CreateGroupAsync(string title, string about = "", CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Creating a megagroup gives us an UpdatesBase with Chats immediately.
|
||||
var updates = await _client.Channels_CreateChannel(title, about, megagroup: true);
|
||||
var channel = updates.Chats.Values.OfType<Channel>().FirstOrDefault()
|
||||
?? throw new InvalidOperationException("Failed to create a supergroup.");
|
||||
|
||||
return new ChatGroup(channel.id, channel.title);
|
||||
}
|
||||
|
||||
public async Task InviteBotToGroupAsync(ChatGroup group, string botUsername, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (botUsername.StartsWith('@'))
|
||||
botUsername = botUsername[1..];
|
||||
|
||||
var resolved = await _client.Contacts_ResolveUsername(botUsername);
|
||||
if (resolved.User is null)
|
||||
throw new InvalidOperationException($"Could not resolve bot @{botUsername}.");
|
||||
|
||||
var channel = await ResolveChannelAsync(group.Id, cancellationToken);
|
||||
var inputUser = new InputUser(resolved.User.id, resolved.User.access_hash);
|
||||
await _client.Channels_InviteToChannel(channel, new InputUserBase[] { inputUser });
|
||||
}
|
||||
|
||||
public async Task SendMessageAsync(ChatGroup group, string text, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var channel = await ResolveChannelAsync(group.Id, cancellationToken);
|
||||
await _client.SendMessageAsync(channel, text);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Message>> GetRecentMessagesAsync(ChatGroup group, int limit = 30, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var channel = await ResolveChannelAsync(group.Id, cancellationToken);
|
||||
var history = await _client.Messages_GetHistory(channel, limit: limit);
|
||||
return history.Messages
|
||||
.OfType<Message>()
|
||||
.OrderByDescending(m => m.Date)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<Message?> WaitForBotReplyAsync(
|
||||
ChatGroup group,
|
||||
string? containsText = null,
|
||||
TimeSpan? timeout = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(30));
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var messages = await GetRecentMessagesAsync(group, limit: 50, cancellationToken);
|
||||
var match = messages.FirstOrDefault(m =>
|
||||
m.from_id is not PeerUser userPeer || userPeer.user_id != _client.UserId);
|
||||
|
||||
if (match is not null)
|
||||
{
|
||||
if (containsText is null || (match.message?.Contains(containsText, StringComparison.OrdinalIgnoreCase) ?? false))
|
||||
return match;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task SendCommandAsync(ChatGroup group, string command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!command.StartsWith('/'))
|
||||
command = "/" + command;
|
||||
|
||||
await SendMessageAsync(group, command, cancellationToken);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.Dispose();
|
||||
}
|
||||
|
||||
private async Task<Channel> ResolveChannelAsync(long channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dialogs = await _client.Messages_GetAllDialogs();
|
||||
var channel = dialogs.chats.Values
|
||||
.OfType<Channel>()
|
||||
.FirstOrDefault(c => c.id == channelId);
|
||||
|
||||
return channel ?? throw new InvalidOperationException($"Could not resolve channel {channelId}.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record ChatGroup(long Id, string Title);
|
||||
Reference in New Issue
Block a user