diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore index 4e750fa..154cafd 100644 --- a/tests/e2e/.gitignore +++ b/tests/e2e/.gitignore @@ -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 diff --git a/tests/e2e/README.md b/tests/e2e/README.md index d71c02e..6b7904b 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -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. diff --git a/tests/e2e/runner/.env.example b/tests/e2e/runner/.env.example new file mode 100644 index 0000000..eeb0de4 --- /dev/null +++ b/tests/e2e/runner/.env.example @@ -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 diff --git a/tests/e2e/runner/.gitignore b/tests/e2e/runner/.gitignore new file mode 100644 index 0000000..cf74b01 --- /dev/null +++ b/tests/e2e/runner/.gitignore @@ -0,0 +1,5 @@ +.env +*.session +bin/ +obj/ +packages.lock.json diff --git a/tests/e2e/runner/GmRelay.E2E.Runner.csproj b/tests/e2e/runner/GmRelay.E2E.Runner.csproj new file mode 100644 index 0000000..9e90869 --- /dev/null +++ b/tests/e2e/runner/GmRelay.E2E.Runner.csproj @@ -0,0 +1,18 @@ + + + + Exe + net10.0 + enable + enable + preview + true + false + + + + + + + + diff --git a/tests/e2e/runner/Program.cs b/tests/e2e/runner/Program.cs new file mode 100644 index 0000000..bc5547f --- /dev/null +++ b/tests/e2e/runner/Program.cs @@ -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."); diff --git a/tests/e2e/runner/RunnerConfig.cs b/tests/e2e/runner/RunnerConfig.cs new file mode 100644 index 0000000..14a0385 --- /dev/null +++ b/tests/e2e/runner/RunnerConfig.cs @@ -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; } +} diff --git a/tests/e2e/runner/TelegramUserClient.cs b/tests/e2e/runner/TelegramUserClient.cs new file mode 100644 index 0000000..863daf4 --- /dev/null +++ b/tests/e2e/runner/TelegramUserClient.cs @@ -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 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().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> 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() + .OrderByDescending(m => m.Date) + .ToList(); + } + + public async Task 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 ResolveChannelAsync(long channelId, CancellationToken cancellationToken = default) + { + var dialogs = await _client.Messages_GetAllDialogs(); + var channel = dialogs.chats.Values + .OfType() + .FirstOrDefault(c => c.id == channelId); + + return channel ?? throw new InvalidOperationException($"Could not resolve channel {channelId}."); + } +} + +public sealed record ChatGroup(long Id, string Title);